diff --git a/android/build.gradle b/android/build.gradle index 1564260273..0267c6e533 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -29,6 +29,7 @@ android { repositories { // Remove this repository line after google releases to google() or mavenCentral() maven { url "https://dl.google.com/android/maven2" } + maven { url "https://s3.amazonaws.com/android.truex.com/mobile/prod/maven/" } } dependencies { @@ -43,6 +44,9 @@ dependencies { implementation 'com.google.android.exoplayer:exoplayer-core:2.18.1' implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.1' implementation 'com.google.android.exoplayer:extension-ima:2.18.1' + implementation 'com.truex:TruexAdRenderer-Mobile:2.1.+' + + compileOnly 'org.checkerframework:checker-qual:3.13.0' // All support libs must use the same version implementation "androidx.annotation:annotation:1.1.0" diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java index 6583f79c8a..12044344dd 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java @@ -11,6 +11,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; +import android.webkit.WebView; import com.google.common.collect.ImmutableList; import com.google.android.exoplayer2.C; diff --git a/android/src/main/java/com/brentvatne/exoplayer/PlaybackHandler.java b/android/src/main/java/com/brentvatne/exoplayer/PlaybackHandler.java new file mode 100644 index 0000000000..8cc1402d85 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/PlaybackHandler.java @@ -0,0 +1,8 @@ +package com.brentvatne.exoplayer; + +public interface PlaybackHandler { + void resumeStream(); + void closeStream(); + void displayLinearAds(); + void handlePopup(String url); +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index a2e8f87f21..28653c4adc 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -15,11 +15,13 @@ import android.view.Display; import android.view.View; import android.view.Window; +import android.view.ViewGroup; import android.view.Gravity; import android.view.accessibility.CaptioningManager; import android.widget.FrameLayout; import android.widget.ImageButton; import android.util.DisplayMetrics; +import android.content.Intent; import com.brentvatne.react.R; import com.brentvatne.receiver.AudioBecomingNoisyReceiver; @@ -38,7 +40,6 @@ import com.google.ads.interactivemedia.v3.api.AdErrorEvent; import com.google.ads.interactivemedia.v3.api.AdsLoader; import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; -import com.google.ads.interactivemedia.v3.api.AdsManager; import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; @@ -49,7 +50,7 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.drm.MediaDrmCallbackException; import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; -import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; +import com.brentvatne.exoplayer.ext.ima.ImaAdsLoader; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackException; @@ -135,16 +136,21 @@ import java.lang.Double; import java.lang.reflect.Method; +import com.facebook.react.uimanager.util.ReactFindViewUtil; + + +import com.brentvatne.exoplayer.PlaybackHandler; + @SuppressLint("ViewConstructor") public class ReactExoplayerView extends FrameLayout implements AdEventListener, - AdsLoader.AdsLoadedListener, LifecycleEventListener, Player.Listener, BandwidthMeter.EventListener, BecomingNoisyListener, AudioManager.OnAudioFocusChangeListener, - DrmSessionEventListener { + DrmSessionEventListener, + PlaybackHandler { public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1; public static final double DEFAULT_MIN_BACK_BUFFER_MEMORY_RESERVE = 0; @@ -170,15 +176,18 @@ public class ReactExoplayerView extends FrameLayout implements private Player.Listener eventListener; private MediaSourceEventListener mediaSourceEventListener; - private ExoPlayerView exoPlayerView; - private FrameLayout adOverlay; - private ImaAdsLoader adsLoader; + public ExoPlayerView exoPlayerView; + public static FrameLayout truexOverlayFrameLayout; + public ImaAdsLoader adsLoader; private AdsLoader googleAdsLoader; + private TruexAdManager truexAdManager; + private FrameLayout truexViewGroup; private ImaSdkSettings imaSettings; private boolean shouldPlayAdBeforeStartPosition; - private AdsManager googleAdsManager; private Ad activeAd; private ArrayList adMarkers; + private boolean isAdsManagerListenerAdded = false; + private Timeline playerTimeline; private DataSource.Factory mediaDataSourceFactory; private ExoPlayer player; @@ -221,6 +230,7 @@ public class ReactExoplayerView extends FrameLayout implements private Uri srcUri; private String extension; private boolean isCSAIEnabled = false; + private boolean isTruexEnabled = false; private String adTagUrl; private boolean repeat; private String audioTrackType; @@ -337,7 +347,6 @@ private void createViews() { LayoutParams.MATCH_PARENT); exoPlayerView = new ExoPlayerView(getContext()); exoPlayerView.setLayoutParams(layoutParams); - // Add Exoplayer view addView(exoPlayerView, 0, layoutParams); @@ -390,12 +399,79 @@ private WritableMap getAdInfo() { return data; } + /** + * TrueX Playback Handlers + */ + @Override + public void resumeStream() { + if (player == null) { + return; + } + + WritableMap payload = Arguments.createMap(); + eventEmitter.adEvent("ENDED_TRUEX", payload); + + adsLoader.discardAdBreak(); + startPlayback(); + } + + @Override + public void closeStream() { + releasePlayer(); + } + @Override - public void onAdsManagerLoaded(AdsManagerLoadedEvent event){ - if (event == null) { + public void displayLinearAds() { + if (this.player == null) { return; } - googleAdsManager = event.getAdsManager(); + + WritableMap payload = Arguments.createMap(); + eventEmitter.adEvent("ENDED_TRUEX", payload); + + adsLoader.skipAd(); + startPlayback(); + } + + @Override + public void handlePopup(String url) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + getContext().startActivity(browserIntent); + } + + /** + * Display TrueX interactive Ad + */ + private void displayInteractiveAd(String vastUrl) { + if (player == null) { + return; + } + WritableMap payload = Arguments.createMap(); + eventEmitter.adEvent("STARTED_TRUEX", payload); + + // Pause the stream and display a true[X] engagement + Long position = player.getCurrentPosition(); + if (position > 0) resumePosition = position; + + // Start the true[X] engagement + ViewGroup viewGroup = (ViewGroup) truexOverlayFrameLayout; + truexAdManager = new TruexAdManager(getContext(), this); + truexAdManager.startAd(viewGroup, vastUrl); + } + + public void handleCheckTruex(AdEvent event) { + if (activeAd == null) { + return; + } + boolean isTrueXAd = activeAd.getAdSystem().contains("trueX"); + if (isTrueXAd && isTruexEnabled) { + String vastUrl = activeAd.getDescription(); + displayInteractiveAd(vastUrl); + } else if (isTrueXAd && !isTruexEnabled) { + // Don't display interactive ads if TrueX is disabled + adsLoader.skipAd(); + } + reLayout(exoPlayerView); } @Override @@ -403,7 +479,6 @@ public void onAdEvent(AdEvent event) { if (event == null || !isCSAIEnabled) { return; } - // Get ad data activeAd = event.getAd(); WritableMap adInfo = getAdInfo(); @@ -414,8 +489,8 @@ public void onAdEvent(AdEvent event) { AdEvent.AdEventType eventType = event.getType(); switch(eventType) { case STARTED: - reLayout(exoPlayerView); eventEmitter.adEvent("STARTED", payload); + handleCheckTruex(event); break; case CONTENT_RESUME_REQUESTED: activeAd = null; @@ -423,7 +498,8 @@ public void onAdEvent(AdEvent event) { break; case TAPPED: eventEmitter.videoClickEvent(); - } + break; + } } @Override @@ -1074,6 +1150,7 @@ private void releasePlayer() { adsLoader = null; imaSettings = null; shouldPlayAdBeforeStartPosition = true; + truexOverlayFrameLayout = null; } if (player != null) { updateResumePosition(); @@ -1128,7 +1205,7 @@ private void setPlayWhenReady(boolean playWhenReady) { } } - private void startPlayback() { + public void startPlayback() { if (player != null) { switch (player.getPlaybackState()) { case Player.STATE_IDLE: @@ -2232,6 +2309,10 @@ public void setEnableCSAI(boolean isEnabled) { this.isCSAIEnabled = isEnabled; } + public void setEnableTruex(boolean isEnabled) { + this.isTruexEnabled = isEnabled; + } + public void setAdTagUrl(String url) { this.adTagUrl = url; } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java index 00ad9e9d43..6e18aa1db1 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java @@ -37,6 +37,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager { + truexAdRenderer.start(viewGroup); + }); + } + + /** + * Inform the true[X] ad renderer that the application has resumed + */ + public void onResume() { + Log.d(CLASSTAG, "onResume"); + truexAdRenderer.resume(); + } + + /** + * Inform the true[X] ad renderer that the application has paused + */ + public void onPause() { + Log.d(CLASSTAG, "onPause"); + truexAdRenderer.pause(); + + } + + /** + * Inform that the true[X] ad renderer that the application has stopped + */ + public void onStop() { + Log.d(CLASSTAG, "onStop"); + } + + /** + * Inform that the true[X] ad renderer that the application has been destroyed + */ + public void onDestroy() { + Log.d(CLASSTAG, "onDestroy"); + truexAdRenderer.destroy(); + } + + /** + * [4] - Integration Doc/Notes + * This method should be called once the true[X] ad manager is done + */ + private void onCompletion() { + if (didReceiveCredit) { + // The user received true[ATTENTION] credit + // Resume the content stream (and skip any linear ads) + playbackHandler.resumeStream(); + } else { + // The user did not receive credit + // Continue the content stream and display linear ads + playbackHandler.displayLinearAds(); + } + } + + /* + Note: This event is triggered when the init call is finished, and the ad is fetched/ready + */ + private IEventHandler adFetchCompleted = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "adFetchCompleted"); + // Truex Ad Renderer is ready to start() if not started in the init callback + }; + + /* + Note: This event is triggered when the ad starts + */ + private IEventHandler adStarted = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "adStarted"); + }; + + /* + Note: This event is triggered when the engagement is completed, + either by the completion of the engagement or the user exiting the engagement + */ + private IEventHandler adCompleted = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "adCompleted"); + + // We are now done with the engagement + onCompletion(); + }; + + /* + Note: This event is triggered when an error is encountered by the true[X] ad renderer + */ + private IEventHandler adError = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "adError"); + + // There was an error trying to load the enagement + onCompletion(); + }; + + /* + Note: This event is triggered if the engagement fails to load, + as a result of there being no engagements available + */ + private IEventHandler noAds = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "noAds"); + + // There are no engagements available + onCompletion(); + }; + + /* + [3] - Integration Doc/Notes + Note: This event is triggered when the viewer has earned their true[ATTENTION] credit. We + could skip over the linear ads here, so that when the ad is complete, all we would need + to do is resume the stream. + */ + private IEventHandler adFree = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "adFree"); + didReceiveCredit = true; + }; + + /* + Note: This event is triggered when a user cancels an interactive engagement + */ + private IEventHandler userCancel = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "userCancel"); + }; + + /* + Note: This event is triggered when a user opts-in to an interactive engagement + */ + private IEventHandler optIn = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "optIn"); + }; + + /* + Note: This event is triggered when a user opts-out of an interactive engagement, + either by time-out, or by choice + */ + private IEventHandler optOut = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "optOut"); + }; + + /* + Note: This event is triggered when a skip card is being displayed to the user + This occurs when a user is able to skip ads + */ + private IEventHandler skipCardShown = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "skipCardShown"); + }; + + /* + Note: This event is triggered when a pop up is to be displayed. Publisher app is + responsible for pausing/resuming the Truex Ad Renderer + */ + private IEventHandler popUp = (TruexAdEvent event, Map data) -> { + Log.d(CLASSTAG, "popUp"); + + String url = (String)data.get("url"); + playbackHandler.handlePopup(url); + }; +} + diff --git a/android/src/main/java/com/brentvatne/exoplayer/ext/ima/AdTagLoader.java b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/AdTagLoader.java new file mode 100644 index 0000000000..775c56da79 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/AdTagLoader.java @@ -0,0 +1,1509 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.brentvatne.exoplayer.ext.ima; + +import static com.google.android.exoplayer2.Player.COMMAND_GET_VOLUME; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.BITRATE_UNSET; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.TIMEOUT_UNSET; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.getAdGroupTimesUsForCuePoints; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.getImaLooper; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static java.lang.Math.max; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import android.view.ViewGroup; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdError; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; +import com.google.ads.interactivemedia.v3.api.AdsLoader; +import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener; +import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; +import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; +import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; +import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener; +import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; +import com.google.android.exoplayer2.ui.AdOverlayInfo; +import com.google.android.exoplayer2.ui.AdViewProvider; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Handles loading and playback of a single ad tag. */ +/* package */ final class AdTagLoader implements Player.Listener { + + private static final String TAG = "AdTagLoader"; + + private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima"; + private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION; + + /** + * Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 200 ms is + * the interval recommended by the Media Rating Council (MRC) for minimum polling of viewable + * video impressions. + * http://www.mediaratingcouncil.org/063014%20Viewable%20Ad%20Impression%20Guideline_Final.pdf. + * + * @see VideoAdPlayer.VideoAdPlayerCallback + */ + private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 200; + + /** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */ + private static final long IMA_DURATION_UNSET = -1L; + + /** + * Threshold before the end of content at which IMA is notified that content is complete if the + * player buffers, in milliseconds. + */ + private static final long THRESHOLD_END_OF_CONTENT_MS = 5000; + /** + * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in + * milliseconds. + */ + private static final long THRESHOLD_AD_PRELOAD_MS = 4000; + /** The threshold below which ad cue points are treated as matching, in microseconds. */ + private static final long THRESHOLD_AD_MATCH_US = 1000; + + /** The state of ad playback. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED}) + private @interface ImaAdState {} + + /** The ad playback state when IMA is not playing an ad. */ + private static final int IMA_AD_STATE_NONE = 0; + /** + * The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not + * {@link ComponentListener##pauseAd(AdMediaInfo)}. + */ + private static final int IMA_AD_STATE_PLAYING = 1; + /** + * The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while + * playing an ad. + */ + private static final int IMA_AD_STATE_PAUSED = 2; + + private final ImaUtil.Configuration configuration; + private final ImaUtil.ImaFactory imaFactory; + private final List supportedMimeTypes; + private final DataSpec adTagDataSpec; + private final Object adsId; + private final Timeline.Period period; + private final Handler handler; + private final ComponentListener componentListener; + private final List eventListeners; + private final List adCallbacks; + private final Runnable updateAdProgressRunnable; + private final BiMap adInfoByAdMediaInfo; + private final AdDisplayContainer adDisplayContainer; + private final AdsLoader adsLoader; + + @Nullable private Object pendingAdRequestContext; + @Nullable private Player player; + private VideoProgressUpdate lastContentProgress; + private VideoProgressUpdate lastAdProgress; + private int lastVolumePercent; + + @Nullable private AdsManager adsManager; + private boolean isAdsManagerInitialized; + @Nullable private AdLoadException pendingAdLoadError; + private Timeline timeline; + private long contentDurationMs; + private AdPlaybackState adPlaybackState; + + private boolean released; + + // Fields tracking IMA's state. + + /** Whether IMA has sent an ad event to pause content since the last resume content event. */ + private boolean imaPausedContent; + /** The current ad playback state. */ + private @ImaAdState int imaAdState; + /** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdMediaInfo imaAdMediaInfo; + /** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdInfo imaAdInfo; + /** Whether IMA has been notified that playback of content has finished. */ + private boolean sentContentComplete; + + // Fields tracking the player/loader state. + + /** Whether the player is playing an ad. */ + private boolean playingAd; + /** Whether the player is buffering an ad. */ + private boolean bufferingAd; + /** + * If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET} + * otherwise. + */ + private int playingAdIndexInAdGroup; + /** + * The ad info for a pending ad for which the media failed preparation, or {@code null} if no + * pending ads have failed to prepare. + */ + @Nullable private AdInfo pendingAdPrepareErrorAdInfo; + /** + * If a content period has finished but IMA has not yet called {@link + * ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link + * SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine + * a fake, increasing content position. {@link C#TIME_UNSET} otherwise. + */ + private long fakeContentProgressElapsedRealtimeMs; + /** + * If {@link #fakeContentProgressElapsedRealtimeMs} is set, stores the offset from which the + * content progress should increase. {@link C#TIME_UNSET} otherwise. + */ + private long fakeContentProgressOffsetMs; + /** Stores the pending content position when a seek operation was intercepted to play an ad. */ + private long pendingContentPositionMs; + /** + * Whether {@link ComponentListener#getContentProgress()} has sent {@link + * #pendingContentPositionMs} to IMA. + */ + private boolean sentPendingContentPositionMs; + /** + * Stores the real time in milliseconds at which the player started buffering, possibly due to not + * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. + */ + private long waitingForPreloadElapsedRealtimeMs; + + /** Creates a new ad tag loader, starting the ad request if the ad tag is valid. */ + @SuppressWarnings({"nullness:methodref.receiver.bound", "nullness:method.invocation"}) + public AdTagLoader( + Context context, + ImaUtil.Configuration configuration, + ImaUtil.ImaFactory imaFactory, + List supportedMimeTypes, + DataSpec adTagDataSpec, + Object adsId, + @Nullable ViewGroup adViewGroup) { + this.configuration = configuration; + this.imaFactory = imaFactory; + @Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings; + if (imaSdkSettings == null) { + imaSdkSettings = imaFactory.createImaSdkSettings(); + if (configuration.debugModeEnabled) { + imaSdkSettings.setDebugMode(true); + } + } + imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); + imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); + this.supportedMimeTypes = supportedMimeTypes; + this.adTagDataSpec = adTagDataSpec; + this.adsId = adsId; + period = new Timeline.Period(); + handler = Util.createHandler(getImaLooper(), /* callback= */ null); + componentListener = new ComponentListener(); + eventListeners = new ArrayList<>(); + adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); + if (configuration.applicationVideoAdPlayerCallback != null) { + adCallbacks.add(configuration.applicationVideoAdPlayerCallback); + } + updateAdProgressRunnable = this::updateAdProgress; + adInfoByAdMediaInfo = HashBiMap.create(); + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + pendingContentPositionMs = C.TIME_UNSET; + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + contentDurationMs = C.TIME_UNSET; + timeline = Timeline.EMPTY; + adPlaybackState = AdPlaybackState.NONE; + if (adViewGroup != null) { + adDisplayContainer = + imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener); + } else { + adDisplayContainer = + imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener); + } + if (configuration.companionAdSlots != null) { + adDisplayContainer.setCompanionSlots(configuration.companionAdSlots); + } + adsLoader = requestAds(context, imaSdkSettings, adDisplayContainer); + } + + /** Returns the underlying IMA SDK ads loader. */ + public AdsLoader getAdsLoader() { + return adsLoader; + } + + /** Returns the IMA SDK ad display container. */ + public AdDisplayContainer getAdDisplayContainer() { + return adDisplayContainer; + } + + /** Skips the current skippable ad, if there is one. */ + public void skipAd() { + if (adsManager != null) { + if(imaAdInfo != null) { + imaAdState = IMA_AD_STATE_NONE; + adPlaybackState = adPlaybackState.withSkippedAd(imaAdInfo.adGroupIndex, imaAdInfo.adIndexInAdGroup); + updateAdPlaybackState(); + adsManager.skip(); + } + } else { + Log.w("RNV_IMA_ADS_LOADER", "No ads manager! - cannot skip ads!"); + } + } + + /** Discards ad breaks */ + public void discardAdBreak() { + if (adsManager != null) { + if(imaAdInfo != null) { + imaAdState = IMA_AD_STATE_NONE; + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); + updateAdPlaybackState(); + adsManager.discardAdBreak(); + } + } else { + Log.w("RNV_IMA_ADS_LOADER", "No ads manager! - cannot discard ad breaks!"); + } + } + + /** + * Moves UI focus to the skip button (or other interactive elements), if currently shown. See + * {@link AdsManager#focus()}. + */ + public void focusSkipButton() { + if (adsManager != null) { + adsManager.focus(); + } + } + + /** + * Starts passing events from this instance (including any pending ad playback state) and + * registers obstructions. + */ + public void addListenerWithAdView(EventListener eventListener, AdViewProvider adViewProvider) { + boolean isStarted = !eventListeners.isEmpty(); + eventListeners.add(eventListener); + if (isStarted) { + if (!AdPlaybackState.NONE.equals(adPlaybackState)) { + // Pass the existing ad playback state to the new listener. + eventListener.onAdPlaybackState(adPlaybackState); + } + return; + } + lastVolumePercent = 0; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + maybeNotifyPendingAdLoadError(); + if (!AdPlaybackState.NONE.equals(adPlaybackState)) { + // Pass the ad playback state to the player, and resume ads if necessary. + eventListener.onAdPlaybackState(adPlaybackState); + } else if (adsManager != null) { + adPlaybackState = + new AdPlaybackState(adsId, getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints())); + updateAdPlaybackState(); + } + for (AdOverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { + adDisplayContainer.registerFriendlyObstruction( + imaFactory.createFriendlyObstruction( + overlayInfo.view, + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), + overlayInfo.reasonDetail)); + } + } + + /** + * Populates the ad playback state with loaded cue points, if available. Any preroll will be + * paused immediately while waiting for this instance to be {@link #activate(Player) activated}. + */ + public void maybePreloadAds(long contentPositionMs, long contentDurationMs) { + maybeInitializeAdsManager(contentPositionMs, contentDurationMs); + } + + /** Activates playback. */ + public void activate(Player player) { + this.player = player; + player.addListener(this); + + boolean playWhenReady = player.getPlayWhenReady(); + onTimelineChanged(player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + @Nullable AdsManager adsManager = this.adsManager; + if (!AdPlaybackState.NONE.equals(adPlaybackState) && adsManager != null && imaPausedContent) { + // Check whether the current ad break matches the expected ad break based on the current + // position. If not, discard the current ad break so that the correct ad break can load. + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + int adGroupForPositionIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + Util.msToUs(contentPositionMs), Util.msToUs(contentDurationMs)); + if (adGroupForPositionIndex != C.INDEX_UNSET + && imaAdInfo != null + && imaAdInfo.adGroupIndex != adGroupForPositionIndex) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "Discarding preloaded ad " + imaAdInfo); + } + adsManager.discardAdBreak(); + } + if (playWhenReady) { + adsManager.resume(); + } + } + } + + /** Deactivates playback. */ + public void deactivate() { + Player player = checkNotNull(this.player); + if (!AdPlaybackState.NONE.equals(adPlaybackState) && imaPausedContent) { + if (adsManager != null) { + adsManager.pause(); + } + adPlaybackState = + adPlaybackState.withAdResumePositionUs( + playingAd ? Util.msToUs(player.getCurrentPosition()) : 0); + } + lastVolumePercent = getPlayerVolumePercent(); + lastAdProgress = getAdVideoProgressUpdate(); + lastContentProgress = getContentVideoProgressUpdate(); + + player.removeListener(this); + this.player = null; + } + + /** Stops passing of events from this instance and unregisters obstructions. */ + public void removeListener(EventListener eventListener) { + eventListeners.remove(eventListener); + if (eventListeners.isEmpty()) { + adDisplayContainer.unregisterAllFriendlyObstructions(); + } + } + + /** Releases all resources used by the ad tag loader. */ + public void release() { + if (released) { + return; + } + released = true; + pendingAdRequestContext = null; + destroyAdsManager(); + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); + } + adsLoader.release(); + imaPausedContent = false; + imaAdState = IMA_AD_STATE_NONE; + imaAdMediaInfo = null; + stopUpdatingAdProgress(); + imaAdInfo = null; + pendingAdLoadError = null; + // No more ads will play once the loader is released, so mark all ad groups as skipped. + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + updateAdPlaybackState(); + } + + /** Notifies the IMA SDK that the specified ad has been prepared for playback. */ + public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) { + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Prepared ad " + adInfo); + } + @Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo); + if (adMediaInfo != null) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onLoaded(adMediaInfo); + } + } else { + Log.w(TAG, "Unexpected prepared ad " + adInfo); + } + } + + /** Notifies the IMA SDK that the specified ad has failed to prepare for playback. */ + public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) { + if (player == null) { + return; + } + try { + handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); + } catch (RuntimeException e) { + maybeNotifyInternalError("handlePrepareError", e); + } + } + + // Player.Listener implementation. + + @Override + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + if (timeline.isEmpty()) { + // The player is being reset or contains no media. + return; + } + this.timeline = timeline; + Player player = checkNotNull(this.player); + long contentDurationUs = timeline.getPeriod(player.getCurrentPeriodIndex(), period).durationUs; + contentDurationMs = Util.usToMs(contentDurationUs); + if (contentDurationUs != adPlaybackState.contentDurationUs) { + adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); + updateAdPlaybackState(); + } + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + maybeInitializeAdsManager(contentPositionMs, contentDurationMs); + handleTimelineOrPositionChanged(); + } + + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + handleTimelineOrPositionChanged(); + } + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + @Nullable Player player = this.player; + if (adsManager == null || player == null) { + return; + } + + if (playbackState == Player.STATE_BUFFERING + && !player.isPlayingAd() + && isWaitingForAdToLoad()) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + + handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + if (adsManager == null || player == null) { + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { + adsManager.pause(); + return; + } + + if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) { + adsManager.resume(); + return; + } + handlePlayerStateChanged(playWhenReady, player.getPlaybackState()); + } + + @Override + public void onPlayerError(PlaybackException error) { + if (imaAdState != IMA_AD_STATE_NONE) { + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + } + + // Internal methods. + + private AdsLoader requestAds( + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { + AdsLoader adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); + adsLoader.addAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.addAdErrorListener(configuration.applicationAdErrorListener); + } + adsLoader.addAdsLoadedListener(componentListener); + AdsRequest request; + try { + request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec); + } catch (IOException e) { + adPlaybackState = new AdPlaybackState(adsId); + updateAdPlaybackState(); + pendingAdLoadError = AdLoadException.createForAllAds(e); + maybeNotifyPendingAdLoadError(); + return adsLoader; + } + pendingAdRequestContext = new Object(); + request.setUserRequestContext(pendingAdRequestContext); + if (configuration.enableContinuousPlayback != null) { + request.setContinuousPlayback(configuration.enableContinuousPlayback); + } + if (configuration.vastLoadTimeoutMs != TIMEOUT_UNSET) { + request.setVastLoadTimeout(configuration.vastLoadTimeoutMs); + } + request.setContentProgressProvider(componentListener); + adsLoader.requestAds(request); + return adsLoader; + } + + private void maybeInitializeAdsManager(long contentPositionMs, long contentDurationMs) { + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + @Nullable + AdsRenderingSettings adsRenderingSettings = + setupAdsRendering(contentPositionMs, contentDurationMs); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } + } + updateAdPlaybackState(); + } + } + + /** + * Configures ads rendering for starting playback, returning the settings for the IMA SDK or + * {@code null} if no ads should play. + */ + @Nullable + private AdsRenderingSettings setupAdsRendering(long contentPositionMs, long contentDurationMs) { + AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); + adsRenderingSettings.setEnablePreloading(true); + adsRenderingSettings.setMimeTypes( + configuration.adMediaMimeTypes != null + ? configuration.adMediaMimeTypes + : supportedMimeTypes); + if (configuration.mediaLoadTimeoutMs != TIMEOUT_UNSET) { + adsRenderingSettings.setLoadVideoTimeout(configuration.mediaLoadTimeoutMs); + } + if (configuration.mediaBitrate != BITRATE_UNSET) { + adsRenderingSettings.setBitrateKbps(configuration.mediaBitrate / 1000); + } + adsRenderingSettings.setFocusSkipButtonWhenAvailable( + configuration.focusSkipButtonWhenAvailable); + if (configuration.adUiElements != null) { + adsRenderingSettings.setUiElements(configuration.adUiElements); + } + + // Skip ads based on the start position as required. + int adGroupForPositionIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + Util.msToUs(contentPositionMs), Util.msToUs(contentDurationMs)); + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + adPlaybackState.getAdGroup(adGroupForPositionIndex).timeUs + == Util.msToUs(contentPositionMs) + || configuration.playAdBeforeStartPosition; + if (!playAdWhenStartingPlayback) { + adGroupForPositionIndex++; + } else if (hasMidrollAdGroups(adPlaybackState)) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be + // cleared. + pendingContentPositionMs = contentPositionMs; + } + if (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + if (adGroupForPositionIndex == adPlaybackState.adGroupCount) { + // We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP + // ads, we signal that no ads will render so the caller can destroy the ads manager. + return null; + } + long adGroupForPositionTimeUs = adPlaybackState.getAdGroup(adGroupForPositionIndex).timeUs; + long adGroupBeforePositionTimeUs = + adPlaybackState.getAdGroup(adGroupForPositionIndex - 1).timeUs; + if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) { + // Play the postroll by offsetting the start position just past the last non-postroll ad. + adsRenderingSettings.setPlayAdsAfterTime( + (double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d); + } else { + // Play ads after the midpoint between the ad to play and the one before it, to avoid + // issues with rounding one of the two ad times. + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + } + } + } + return adsRenderingSettings; + } + + private VideoProgressUpdate getContentVideoProgressUpdate() { + boolean hasContentDuration = contentDurationMs != C.TIME_UNSET; + long contentPositionMs; + if (pendingContentPositionMs != C.TIME_UNSET) { + sentPendingContentPositionMs = true; + contentPositionMs = pendingContentPositionMs; + } else if (player == null) { + return lastContentProgress; + } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { + long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; + contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; + } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { + contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + } else { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET; + return new VideoProgressUpdate(contentPositionMs, contentDurationMs); + } + + private VideoProgressUpdate getAdVideoProgressUpdate() { + if (player == null) { + return lastAdProgress; + } else if (imaAdState != IMA_AD_STATE_NONE && playingAd) { + long adDuration = player.getDuration(); + return adDuration == C.TIME_UNSET + ? VideoProgressUpdate.VIDEO_TIME_NOT_READY + : new VideoProgressUpdate(player.getCurrentPosition(), adDuration); + } else { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + } + + private void updateAdProgress() { + VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Ad progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate)); + } + + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate); + } + handler.removeCallbacks(updateAdProgressRunnable); + handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS); + } + + private void stopUpdatingAdProgress() { + handler.removeCallbacks(updateAdProgressRunnable); + } + + private int getPlayerVolumePercent() { + @Nullable Player player = this.player; + if (player == null) { + return lastVolumePercent; + } + + if (player.isCommandAvailable(COMMAND_GET_VOLUME)) { + return (int) (player.getVolume() * 100); + } + + // Check for a selected track using an audio renderer. + return player.getCurrentTracks().isTypeSelected(C.TRACK_TYPE_AUDIO) ? 100 : 0; + } + + private void handleAdEvent(AdEvent adEvent) { + if (adsManager == null) { + // Drop events after release. + return; + } + switch (adEvent.getType()) { + case AD_BREAK_FETCH_ERROR: + String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime")); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); + } + double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); + int adGroupIndex = + adGroupTimeSeconds == -1.0 + ? adPlaybackState.adGroupCount - 1 + : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + break; + case CONTENT_PAUSE_REQUESTED: + // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads + // before sending CONTENT_RESUME_REQUESTED. + imaPausedContent = true; + pauseContentInternal(); + break; + case TAPPED: + for (int i = 0; i < eventListeners.size(); i++) { + eventListeners.get(i).onAdTapped(); + } + break; + case CLICKED: + for (int i = 0; i < eventListeners.size(); i++) { + eventListeners.get(i).onAdClicked(); + } + break; + case CONTENT_RESUME_REQUESTED: + imaPausedContent = false; + resumeContentInternal(); + break; + case LOG: + Map adData = adEvent.getAdData(); + String message = "AdEvent: " + adData; + Log.i(TAG, message); + break; + default: + break; + } + } + + private void pauseContentInternal() { + imaAdState = IMA_AD_STATE_NONE; + if (sentPendingContentPositionMs) { + pendingContentPositionMs = C.TIME_UNSET; + sentPendingContentPositionMs = false; + } + } + + private void resumeContentInternal() { + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); + updateAdPlaybackState(); + } + } + + /** + * Returns whether this instance is expecting the first ad in an the upcoming ad group to load + * within the {@link ImaUtil.Configuration#adPreloadTimeoutMs preload timeout}. + */ + private boolean isWaitingForAdToLoad() { + @Nullable Player player = this.player; + if (player == null) { + return false; + } + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + return false; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + if (adGroup.count != C.LENGTH_UNSET + && adGroup.count != 0 + && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + // An ad is available already. + return false; + } + long adGroupTimeMs = Util.usToMs(adGroup.timeUs); + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + long timeUntilAdMs = adGroupTimeMs - contentPositionMs; + return timeUntilAdMs < configuration.adPreloadTimeoutMs; + } + + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { + if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { + bufferingAd = true; + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onBuffering(adMediaInfo); + } + stopUpdatingAdProgress(); + } else if (bufferingAd && playbackState == Player.STATE_READY) { + bufferingAd = false; + updateAdProgress(); + } + } + + if (imaAdState == IMA_AD_STATE_NONE + && playbackState == Player.STATE_BUFFERING + && playWhenReady) { + ensureSentContentCompleteIfAtEndOfStream(); + } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { + @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } + } + if (configuration.debugModeEnabled) { + Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged"); + } + } + } + + private void handleTimelineOrPositionChanged() { + @Nullable Player player = this.player; + if (adsManager == null || player == null) { + return; + } + if (!playingAd && !player.isPlayingAd()) { + ensureSentContentCompleteIfAtEndOfStream(); + if (!sentContentComplete && !timeline.isEmpty()) { + long positionMs = getContentPeriodPositionMs(player, timeline, period); + timeline.getPeriod(player.getCurrentPeriodIndex(), period); + int newAdGroupIndex = period.getAdGroupIndexForPositionUs(Util.msToUs(positionMs)); + if (newAdGroupIndex != C.INDEX_UNSET) { + sentPendingContentPositionMs = false; + pendingContentPositionMs = positionMs; + } + } + } + + boolean wasPlayingAd = playingAd; + int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; + playingAd = player.isPlayingAd(); + playingAdIndexInAdGroup = playingAd ? player.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET; + boolean adFinished = wasPlayingAd && playingAdIndexInAdGroup != oldPlayingAdIndexInAdGroup; + if (adFinished) { + // IMA is waiting for the ad playback to finish so invoke the callback now. + // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again. + @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (playingAdIndexInAdGroup == C.INDEX_UNSET + || (adInfo != null && adInfo.adIndexInAdGroup < playingAdIndexInAdGroup)) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } + if (configuration.debugModeEnabled) { + Log.d( + TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); + } + } + } + } + if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(player.getCurrentAdGroupIndex()); + if (adGroup.timeUs == C.TIME_END_OF_SOURCE) { + sendContentComplete(); + } else { + // IMA hasn't called playAd yet, so fake the content position. + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + fakeContentProgressOffsetMs = Util.usToMs(adGroup.timeUs); + if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { + fakeContentProgressOffsetMs = contentDurationMs; + } + } + } + } + + private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + if (adsManager == null) { + // Drop events after release. + if (configuration.debugModeEnabled) { + Log.d( + TAG, + "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); + } + return; + } + + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + // The ad URI may already be known, so force put to update it if needed. + adInfoByAdMediaInfo.forcePut(adMediaInfo, adInfo); + if (configuration.debugModeEnabled) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA will + // timeout after its media load timeout. + return; + } + + // The ad count may increase on successive loads of ads in the same ad pod, for example, due to + // separate requests for ad tags with multiple ads within the ad pod completing after an earlier + // ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477. + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adInfo.adGroupIndex); + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.getAdGroup(adInfo.adGroupIndex); + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + + Uri adUri = Uri.parse(adMediaInfo.getUrl()); + adPlaybackState = + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); + updateAdPlaybackState(); + } + + private void playAdInternal(AdMediaInfo adMediaInfo) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop events after release. + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); + } + + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (player == null || !player.getPlayWhenReady()) { + // Either this loader hasn't been activated yet, or the player is paused now. + checkNotNull(adsManager).pause(); + } + } + + private void pauseAdInternal(AdMediaInfo adMediaInfo) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called if loadAd has been called but the loaded ad won't play due to a seek + // to a different position, so drop the event. See also [Internal: b/159111848]. + return; + } + if (configuration.debugModeEnabled && !adMediaInfo.equals(imaAdMediaInfo)) { + Log.w( + TAG, + "Unexpected pauseAd for " + + getAdMediaInfoString(adMediaInfo) + + ", expected " + + getAdMediaInfoString(imaAdMediaInfo)); + } + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } + + private void stopAdInternal(AdMediaInfo adMediaInfo) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called if loadAd has been called but the preloaded ad won't play due to a + // seek to a different position, so drop the event and discard the ad. See also [Internal: + // b/159111848]. + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (adInfo != null) { + adPlaybackState = + adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup); + updateAdPlaybackState(); + } + return; + } + imaAdState = IMA_AD_STATE_NONE; + stopUpdatingAdProgress(); + // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + checkNotNull(imaAdInfo); + int adGroupIndex = imaAdInfo.adGroupIndex; + int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. + return; + } + adPlaybackState = + adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); + updateAdPlaybackState(); + if (!playingAd) { + imaAdMediaInfo = null; + imaAdInfo = null; + } + } + + private void handleAdGroupLoadError(Exception error) { + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + Log.w(TAG, "Unable to determine ad group index for ad group load error", error); + return; + } + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex); + } + } + + private void markAdGroupInErrorStateAndClearPendingContentPosition(int adGroupIndex) { + // Update the ad playback state so all ads in the ad group are in the error state. + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length)); + adGroup = adPlaybackState.getAdGroup(adGroupIndex); + } + for (int i = 0; i < adGroup.count; i++) { + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); + } + } + updateAdPlaybackState(); + // Clear any pending content position that triggered attempting to load the ad group. + pendingContentPositionMs = C.TIME_UNSET; + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + } + + private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) { + if (configuration.debugModeEnabled) { + Log.d( + TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception); + } + if (adsManager == null) { + Log.w(TAG, "Ignoring ad prepare error after release"); + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // Send IMA a content position at the ad group so that it will try to play it, at which point + // we can notify that it failed to load. + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + fakeContentProgressOffsetMs = Util.usToMs(adPlaybackState.getAdGroup(adGroupIndex).timeUs); + if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { + fakeContentProgressOffsetMs = contentDurationMs; + } + pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + } else { + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + // We're already playing an ad. + if (adIndexInAdGroup > playingAdIndexInAdGroup) { + // Mark the playing ad as ended so we can notify the error on the next ad and remove it, + // which means that the ad after will load (if any). + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } + } + playingAdIndexInAdGroup = adPlaybackState.getAdGroup(adGroupIndex).getFirstAdIndexToPlay(); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(checkNotNull(adMediaInfo)); + } + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); + updateAdPlaybackState(); + } + + private void ensureSentContentCompleteIfAtEndOfStream() { + if (!sentContentComplete + && contentDurationMs != C.TIME_UNSET + && pendingContentPositionMs == C.TIME_UNSET + && getContentPeriodPositionMs(checkNotNull(player), timeline, period) + + THRESHOLD_END_OF_CONTENT_MS + >= contentDurationMs) { + sendContentComplete(); + } + } + + private void sendContentComplete() { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onContentComplete(); + } + sentContentComplete = true; + if (configuration.debugModeEnabled) { + Log.d(TAG, "adsLoader.contentComplete"); + } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + if (adPlaybackState.getAdGroup(i).timeUs != C.TIME_END_OF_SOURCE) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); + } + } + updateAdPlaybackState(); + } + + private void updateAdPlaybackState() { + for (int i = 0; i < eventListeners.size(); i++) { + eventListeners.get(i).onAdPlaybackState(adPlaybackState); + } + } + + private void maybeNotifyPendingAdLoadError() { + if (pendingAdLoadError != null) { + for (int i = 0; i < eventListeners.size(); i++) { + eventListeners.get(i).onAdLoadError(pendingAdLoadError, adTagDataSpec); + } + pendingAdLoadError = null; + } + } + + private void maybeNotifyInternalError(String name, Exception cause) { + String message = "Internal error in " + name; + Log.e(TAG, message, cause); + // We can't recover from an unexpected error in general, so skip all remaining ads. + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + updateAdPlaybackState(); + for (int i = 0; i < eventListeners.size(); i++) { + eventListeners + .get(i) + .onAdLoadError( + AdLoadException.createForUnexpected(new RuntimeException(message, cause)), + adTagDataSpec); + } + } + + private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { + if (adPodInfo.getPodIndex() == -1) { + // This is a postroll ad. + return adPlaybackState.adGroupCount - 1; + } + + // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. + return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset()); + } + + /** + * Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is + * no such ad group. + */ + private int getLoadingAdGroupIndex() { + if (player == null) { + return C.INDEX_UNSET; + } + long playerPositionUs = Util.msToUs(getContentPeriodPositionMs(player, timeline, period)); + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + playerPositionUs, Util.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + playerPositionUs, Util.msToUs(contentDurationMs)); + } + return adGroupIndex; + } + + private int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) { + // We receive initial cue points from IMA SDK as floats. This code replicates the same + // calculation used to populate adGroupTimesUs (having truncated input back to float, to avoid + // failures if the behavior of the IMA SDK changes to provide greater precision). + float cuePointTimeSecondsFloat = (float) cuePointTimeSeconds; + long adPodTimeUs = Math.round((double) cuePointTimeSecondsFloat * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + long adGroupTimeUs = adPlaybackState.getAdGroup(adGroupIndex).timeUs; + if (adGroupTimeUs != C.TIME_END_OF_SOURCE + && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { + return adGroupIndex; + } + } + throw new IllegalStateException("Failed to find cue point"); + } + + private String getAdMediaInfoString(@Nullable AdMediaInfo adMediaInfo) { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + return "AdMediaInfo[" + + (adMediaInfo == null ? "null" : adMediaInfo.getUrl()) + + ", " + + adInfo + + "]"; + } + + private static long getContentPeriodPositionMs( + Player player, Timeline timeline, Timeline.Period period) { + long contentWindowPositionMs = player.getContentPosition(); + if (timeline.isEmpty()) { + return contentWindowPositionMs; + } else { + return contentWindowPositionMs + - timeline.getPeriod(player.getCurrentPeriodIndex(), period).getPositionInWindowMs(); + } + } + + private static boolean hasMidrollAdGroups(AdPlaybackState adPlaybackState) { + int count = adPlaybackState.adGroupCount; + if (count == 1) { + long adGroupTimeUs = adPlaybackState.getAdGroup(0).timeUs; + return adGroupTimeUs != 0 && adGroupTimeUs != C.TIME_END_OF_SOURCE; + } else if (count == 2) { + return adPlaybackState.getAdGroup(0).timeUs != 0 + || adPlaybackState.getAdGroup(1).timeUs != C.TIME_END_OF_SOURCE; + } else { + // There's at least one midroll ad group, as adPlaybackState is never empty. + return true; + } + } + + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsManager.removeAdErrorListener(configuration.applicationAdErrorListener); + } + adsManager.removeAdEventListener(componentListener); + if (configuration.applicationAdEventListener != null) { + adsManager.removeAdEventListener(configuration.applicationAdEventListener); + } + adsManager.destroy(); + adsManager = null; + } + } + + private final class ComponentListener + implements AdsLoadedListener, + ContentProgressProvider, + AdEventListener, + AdErrorListener, + VideoAdPlayer { + + // AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); + if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { + adsManager.destroy(); + return; + } + pendingAdRequestContext = null; + AdTagLoader.this.adsManager = adsManager; + adsManager.addAdErrorListener(this); + if (configuration.applicationAdErrorListener != null) { + adsManager.addAdErrorListener(configuration.applicationAdErrorListener); + } + adsManager.addAdEventListener(this); + if (configuration.applicationAdEventListener != null) { + adsManager.addAdEventListener(configuration.applicationAdEventListener); + } + try { + adPlaybackState = + new AdPlaybackState(adsId, getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints())); + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdsManagerLoaded", e); + } + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (configuration.debugModeEnabled) { + Log.d( + TAG, + "Content progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate)); + } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } else if (pendingContentPositionMs != C.TIME_UNSET + && player != null + && player.getPlaybackState() == Player.STATE_BUFFERING + && isWaitingForAdToLoad()) { + // Prepare to timeout the load of an ad for the pending seek operation. + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + + return videoProgressUpdate; + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + AdEventType adEventType = adEvent.getType(); + if (configuration.debugModeEnabled && adEventType != AdEventType.AD_PROGRESS) { + Log.d(TAG, "onAdEvent: " + adEventType); + } + try { + handleAdEvent(adEvent); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdEvent", e); + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "onAdError", error); + } + if (adsManager == null) { + // No ads were loaded, so allow playback to start without any ads. + pendingAdRequestContext = null; + adPlaybackState = new AdPlaybackState(adsId); + updateAdPlaybackState(); + } else if (ImaUtil.isAdGroupLoadError(error)) { + try { + handleAdGroupLoadError(error); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdError", e); + } + } + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAllAds(error); + } + maybeNotifyPendingAdLoadError(); + } + + // VideoAdPlayer implementation. + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @Override + public VideoProgressUpdate getAdProgress() { + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); + } + + @Override + public int getVolume() { + return getPlayerVolumePercent(); + } + + @Override + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + try { + loadAdInternal(adMediaInfo, adPodInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("loadAd", e); + } + } + + @Override + public void playAd(AdMediaInfo adMediaInfo) { + try { + playAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); + } + } + + @Override + public void pauseAd(AdMediaInfo adMediaInfo) { + try { + pauseAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); + } + } + + @Override + public void stopAd(AdMediaInfo adMediaInfo) { + try { + stopAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("stopAd", e); + } + } + + @Override + public void release() { + // Do nothing. + } + } + + // TODO: Consider moving this into AdPlaybackState. + private static final class AdInfo { + + public final int adGroupIndex; + public final int adIndexInAdGroup; + + public AdInfo(int adGroupIndex, int adIndexInAdGroup) { + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdInfo adInfo = (AdInfo) o; + if (adGroupIndex != adInfo.adGroupIndex) { + return false; + } + return adIndexInAdGroup == adInfo.adIndexInAdGroup; + } + + @Override + public int hashCode() { + int result = adGroupIndex; + result = 31 * result + adIndexInAdGroup; + return result; + } + + @Override + public String toString() { + return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')'; + } + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaAdsLoader.java b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaAdsLoader.java new file mode 100644 index 0000000000..1617f2c212 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaAdsLoader.java @@ -0,0 +1,777 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.brentvatne.exoplayer.ext.ima; + +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.BITRATE_UNSET; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.TIMEOUT_UNSET; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.getImaLooper; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; + +import android.content.Context; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.IntRange; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; +import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; +import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; +import com.google.ads.interactivemedia.v3.api.FriendlyObstruction; +import com.google.ads.interactivemedia.v3.api.FriendlyObstructionPurpose; +import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.UiElement; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.ui.AdViewProvider; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import android.util.Log; + +/** + * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread. + * + *

The player instance that will play the loaded ads must be set before playback using {@link + * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling + * {@link #release()}. + * + *

See IMA's + * Support and compatibility page for information on compatible ad tag formats. Pass the ad tag + * URI when setting media item playback properties (if using the media item API) or as a {@link + * DataSpec} when constructing the {@link AdsMediaSource} (if using media sources directly). For the + * latter case, please note that this implementation delegates loading of the data spec to the IMA + * SDK, so range and headers specifications will be ignored in ad tag URIs. Literal ads responses + * can be encoded as data scheme data specs, for example, by constructing the data spec using a URI + * generated via {@link Util#getDataUriForString(String, String)}. + * + *

The IMA SDK can report obstructions to the ad view for accurate viewability measurement. This + * means that any overlay views that obstruct the ad overlay but are essential for playback need to + * be registered via the {@link AdViewProvider} passed to the {@link AdsMediaSource}. See the IMA + * SDK Open Measurement documentation for more information. + */ +public final class ImaAdsLoader implements AdsLoader { + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.ima"); + } + + /** Builder for {@link ImaAdsLoader}. */ + public static final class Builder { + + /** + * The default duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. + * + *

This value should be large enough not to trigger discarding the ad when it actually might + * load soon, but small enough so that user is not waiting for too long. + * + * @see #setAdPreloadTimeoutMs(long) + */ + public static final long DEFAULT_AD_PRELOAD_TIMEOUT_MS = 10 * C.MILLIS_PER_SECOND; + + private final Context context; + + @Nullable private ImaSdkSettings imaSdkSettings; + @Nullable private AdErrorListener adErrorListener; + @Nullable private AdEventListener adEventListener; + @Nullable private VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback; + @Nullable private List adMediaMimeTypes; + @Nullable private Set adUiElements; + @Nullable private Collection companionAdSlots; + @Nullable private Boolean enableContinuousPlayback; + private long adPreloadTimeoutMs; + private int vastLoadTimeoutMs; + private int mediaLoadTimeoutMs; + private int mediaBitrate; + private boolean focusSkipButtonWhenAvailable; + private boolean playAdBeforeStartPosition; + private boolean debugModeEnabled; + private ImaUtil.ImaFactory imaFactory; + + /** + * Creates a new builder for {@link ImaAdsLoader}. + * + * @param context The context; + */ + public Builder(Context context) { + this.context = checkNotNull(context).getApplicationContext(); + adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS; + vastLoadTimeoutMs = TIMEOUT_UNSET; + mediaLoadTimeoutMs = TIMEOUT_UNSET; + mediaBitrate = BITRATE_UNSET; + focusSkipButtonWhenAvailable = true; + playAdBeforeStartPosition = true; + imaFactory = new DefaultImaFactory(); + } + + /** + * Sets the IMA SDK settings. The provided settings instance's player type and version fields + * may be overwritten. + * + *

If this method is not called the default settings will be used. + * + * @param imaSdkSettings The {@link ImaSdkSettings}. + * @return This builder, for convenience. + */ + public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { + this.imaSdkSettings = checkNotNull(imaSdkSettings); + return this; + } + + /** + * Sets a listener for ad errors that will be passed to {@link + * com.google.ads.interactivemedia.v3.api.AdsLoader#addAdErrorListener(AdErrorListener)} and + * {@link AdsManager#addAdErrorListener(AdErrorListener)}. + * + * @param adErrorListener The ad error listener. + * @return This builder, for convenience. + */ + public Builder setAdErrorListener(AdErrorListener adErrorListener) { + this.adErrorListener = checkNotNull(adErrorListener); + return this; + } + + /** + * Sets a listener for ad events that will be passed to {@link + * AdsManager#addAdEventListener(AdEventListener)}. + * + * @param adEventListener The ad event listener. + * @return This builder, for convenience. + */ + public Builder setAdEventListener(AdEventListener adEventListener) { + this.adEventListener = checkNotNull(adEventListener); + return this; + } + + /** + * Sets a callback to receive video ad player events. Note that these events are handled + * internally by the IMA SDK and this ads loader. For analytics and diagnostics, new + * implementations should generally use events from the top-level {@link Player} listeners + * instead of setting a callback via this method. + * + * @param videoAdPlayerCallback The callback to receive video ad player events. + * @return This builder, for convenience. + * @see com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback + */ + public Builder setVideoAdPlayerCallback( + VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback) { + this.videoAdPlayerCallback = checkNotNull(videoAdPlayerCallback); + return this; + } + + /** + * Sets the ad UI elements to be rendered by the IMA SDK. + * + * @param adUiElements The ad UI elements to be rendered by the IMA SDK. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setUiElements(Set) + */ + public Builder setAdUiElements(Set adUiElements) { + this.adUiElements = ImmutableSet.copyOf(checkNotNull(adUiElements)); + return this; + } + + /** + * Sets the slots to use for companion ads, if they are present in the loaded ad. + * + * @param companionAdSlots The slots to use for companion ads. + * @return This builder, for convenience. + * @see AdDisplayContainer#setCompanionSlots(Collection) + */ + public Builder setCompanionAdSlots(Collection companionAdSlots) { + this.companionAdSlots = ImmutableList.copyOf(checkNotNull(companionAdSlots)); + return this; + } + + /** + * Sets the MIME types to prioritize for linear ad media. If not specified, MIME types supported + * by the {@link MediaSource.Factory adMediaSourceFactory} used to construct the {@link + * AdsMediaSource} will be used. + * + * @param adMediaMimeTypes The MIME types to prioritize for linear ad media. May contain {@link + * MimeTypes#APPLICATION_MPD}, {@link MimeTypes#APPLICATION_M3U8}, {@link + * MimeTypes#VIDEO_MP4}, {@link MimeTypes#VIDEO_WEBM}, {@link MimeTypes#VIDEO_H263}, {@link + * MimeTypes#AUDIO_MP4} and {@link MimeTypes#AUDIO_MPEG}. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setMimeTypes(List) + */ + public Builder setAdMediaMimeTypes(List adMediaMimeTypes) { + this.adMediaMimeTypes = ImmutableList.copyOf(checkNotNull(adMediaMimeTypes)); + return this; + } + + /** + * Sets whether to enable continuous playback. Pass {@code true} if content videos will be + * played continuously, similar to a TV broadcast. This setting may modify the ads request but + * does not affect ad playback behavior. The requested value is unknown by default. + * + * @param enableContinuousPlayback Whether to enable continuous playback. + * @return This builder, for convenience. + * @see AdsRequest#setContinuousPlayback(boolean) + */ + public Builder setEnableContinuousPlayback(boolean enableContinuousPlayback) { + this.enableContinuousPlayback = enableContinuousPlayback; + return this; + } + + /** + * Sets the duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. Pass {@link + * C#TIME_UNSET} if there should be no such timeout. The default value is {@value + * #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms. + * + *

The purpose of this timeout is to avoid playback getting stuck in the unexpected case that + * the IMA SDK does not load an ad break based on the player's reported content position. + * + * @param adPreloadTimeoutMs The timeout buffering duration in milliseconds, or {@link + * C#TIME_UNSET} for no timeout. + * @return This builder, for convenience. + */ + public Builder setAdPreloadTimeoutMs(long adPreloadTimeoutMs) { + checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0); + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + return this; + } + + /** + * Sets the VAST load timeout, in milliseconds. + * + * @param vastLoadTimeoutMs The VAST load timeout, in milliseconds. + * @return This builder, for convenience. + * @see AdsRequest#setVastLoadTimeout(float) + */ + public Builder setVastLoadTimeoutMs(@IntRange(from = 1) int vastLoadTimeoutMs) { + checkArgument(vastLoadTimeoutMs > 0); + this.vastLoadTimeoutMs = vastLoadTimeoutMs; + return this; + } + + /** + * Sets the ad media load timeout, in milliseconds. + * + * @param mediaLoadTimeoutMs The ad media load timeout, in milliseconds. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setLoadVideoTimeout(int) + */ + public Builder setMediaLoadTimeoutMs(@IntRange(from = 1) int mediaLoadTimeoutMs) { + checkArgument(mediaLoadTimeoutMs > 0); + this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; + return this; + } + + /** + * Sets the media maximum recommended bitrate for ads, in bps. + * + * @param bitrate The media maximum recommended bitrate for ads, in bps. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setBitrateKbps(int) + */ + public Builder setMaxMediaBitrate(@IntRange(from = 1) int bitrate) { + checkArgument(bitrate > 0); + this.mediaBitrate = bitrate; + return this; + } + + /** + * Sets whether to focus the skip button (when available) on Android TV devices. The default + * setting is {@code true}. + * + * @param focusSkipButtonWhenAvailable Whether to focus the skip button (when available) on + * Android TV devices. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setFocusSkipButtonWhenAvailable(boolean) + */ + public Builder setFocusSkipButtonWhenAvailable(boolean focusSkipButtonWhenAvailable) { + this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + return this; + } + + /** + * Sets whether to play an ad before the start position when beginning playback. If {@code + * true}, an ad will be played if there is one at or before the start position. If {@code + * false}, an ad will be played only if there is one exactly at the start position. The default + * setting is {@code true}. + * + * @param playAdBeforeStartPosition Whether to play an ad before the start position when + * beginning playback. + * @return This builder, for convenience. + */ + public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { + this.playAdBeforeStartPosition = playAdBeforeStartPosition; + return this; + } + + /** + * Sets whether to enable outputting verbose logs for the IMA extension and IMA SDK. The default + * value is {@code false}. This setting is intended for debugging only, and should not be + * enabled in production applications. + * + * @param debugModeEnabled Whether to enable outputting verbose logs for the IMA extension and + * IMA SDK. + * @return This builder, for convenience. + * @see ImaSdkSettings#setDebugMode(boolean) + */ + public Builder setDebugModeEnabled(boolean debugModeEnabled) { + this.debugModeEnabled = debugModeEnabled; + return this; + } + + @VisibleForTesting + /* package */ Builder setImaFactory(ImaUtil.ImaFactory imaFactory) { + this.imaFactory = checkNotNull(imaFactory); + return this; + } + + /** Returns a new {@link ImaAdsLoader}. */ + public ImaAdsLoader build() { + return new ImaAdsLoader( + context, + new ImaUtil.Configuration( + adPreloadTimeoutMs, + vastLoadTimeoutMs, + mediaLoadTimeoutMs, + focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, + mediaBitrate, + enableContinuousPlayback, + adMediaMimeTypes, + adUiElements, + companionAdSlots, + adErrorListener, + adEventListener, + videoAdPlayerCallback, + imaSdkSettings, + debugModeEnabled), + imaFactory); + } + } + + private final ImaUtil.Configuration configuration; + private final Context context; + private final ImaUtil.ImaFactory imaFactory; + private final PlayerListenerImpl playerListener; + private final HashMap adTagLoaderByAdsId; + private final HashMap adTagLoaderByAdsMediaSource; + private final Timeline.Period period; + private final Timeline.Window window; + + private boolean wasSetPlayerCalled; + @Nullable private Player nextPlayer; + private List supportedMimeTypes; + @Nullable private Player player; + @Nullable private AdTagLoader currentAdTagLoader; + + private ImaAdsLoader( + Context context, ImaUtil.Configuration configuration, ImaUtil.ImaFactory imaFactory) { + this.context = context.getApplicationContext(); + this.configuration = configuration; + this.imaFactory = imaFactory; + playerListener = new PlayerListenerImpl(); + supportedMimeTypes = ImmutableList.of(); + adTagLoaderByAdsId = new HashMap<>(); + adTagLoaderByAdsMediaSource = new HashMap<>(); + period = new Timeline.Period(); + window = new Timeline.Window(); + } + + /** + * Returns the underlying {@link com.google.ads.interactivemedia.v3.api.AdsLoader} wrapped by this + * instance, or {@code null} if ads have not been requested yet. + */ + @Nullable + public com.google.ads.interactivemedia.v3.api.AdsLoader getAdsLoader() { + return currentAdTagLoader != null ? currentAdTagLoader.getAdsLoader() : null; + } + + /** + * Returns the {@link AdDisplayContainer} used by this loader, or {@code null} if ads have not + * been requested yet. + * + *

Note: any video controls overlays registered via {@link + * AdDisplayContainer#registerFriendlyObstruction(FriendlyObstruction)} will be unregistered + * automatically when the media source detaches from this instance. It is therefore necessary to + * re-register views each time the ads loader is reused. Alternatively, provide overlay views via + * the {@link AdViewProvider} when creating the media source to benefit from automatic + * registration. + */ + @Nullable + public AdDisplayContainer getAdDisplayContainer() { + return currentAdTagLoader != null ? currentAdTagLoader.getAdDisplayContainer() : null; + } + + /** + * Requests ads, if they have not already been requested. Must be called on the main thread. + * + *

Ads will be requested automatically when the player is prepared if this method has not been + * called, so it is only necessary to call this method if you want to request ads before preparing + * the player. + * + * @param adTagDataSpec The data specification of the ad tag to load. See class javadoc for + * information about compatible ad tag formats. + * @param adsId A opaque identifier for the ad playback state across start/stop calls. + * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code + * null} if playing audio-only ads. + */ + public void requestAds(DataSpec adTagDataSpec, Object adsId, @Nullable ViewGroup adViewGroup) { + if (!adTagLoaderByAdsId.containsKey(adsId)) { + AdTagLoader adTagLoader = + new AdTagLoader( + context, + configuration, + imaFactory, + supportedMimeTypes, + adTagDataSpec, + adsId, + adViewGroup); + adTagLoaderByAdsId.put(adsId, adTagLoader); + } + } + + /** + * Skips the current ad. + * + *

This method is intended for apps that play audio-only ads and so need to provide their own + * UI for users to skip skippable ads. Apps showing video ads should not call this method, as the + * IMA SDK provides the UI to skip ads in the ad view group passed via {@link AdViewProvider}. + */ + public void skipAd() { + if (currentAdTagLoader != null) { + currentAdTagLoader.skipAd(); + } else { + Log.w("RNV_IMA_ADS_LOADER", "No ad tag loader - cannot skip ads!"); + } + } + + public void discardAdBreak() { + if (currentAdTagLoader != null) { + currentAdTagLoader.discardAdBreak(); + } else { + Log.w("RNV_IMA_ADS_LOADER", "No ad tag loader - cannot discard ad break!"); + } + } + + /** + * Moves UI focus to the skip button (or other interactive elements), if currently shown. See + * {@link AdsManager#focus()}. + */ + public void focusSkipButton() { + if (currentAdTagLoader != null) { + currentAdTagLoader.focusSkipButton(); + } + } + + // AdsLoader implementation. + + @Override + public void setPlayer(@Nullable Player player) { + checkState(Looper.myLooper() == getImaLooper()); + checkState(player == null || player.getApplicationLooper() == getImaLooper()); + nextPlayer = player; + wasSetPlayerCalled = true; + Log.w("RNV_IMA_ADS_LOADER", "Player set!"); + } + + @Override + public void setSupportedContentTypes(@C.ContentType int... contentTypes) { + List supportedMimeTypes = new ArrayList<>(); + for (@C.ContentType int contentType : contentTypes) { + // IMA does not support Smooth Streaming ad media. + if (contentType == C.CONTENT_TYPE_DASH) { + supportedMimeTypes.add(MimeTypes.APPLICATION_MPD); + } else if (contentType == C.CONTENT_TYPE_HLS) { + supportedMimeTypes.add(MimeTypes.APPLICATION_M3U8); + } else if (contentType == C.CONTENT_TYPE_OTHER) { + supportedMimeTypes.addAll( + Arrays.asList( + MimeTypes.VIDEO_MP4, + MimeTypes.VIDEO_WEBM, + MimeTypes.VIDEO_H263, + MimeTypes.AUDIO_MP4, + MimeTypes.AUDIO_MPEG)); + } + } + this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes); + } + + @Override + public void start( + AdsMediaSource adsMediaSource, + DataSpec adTagDataSpec, + Object adsId, + AdViewProvider adViewProvider, + EventListener eventListener) { + checkState( + wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player."); + if (adTagLoaderByAdsMediaSource.isEmpty()) { + player = nextPlayer; + @Nullable Player player = this.player; + if (player == null) { + return; + } + player.addListener(playerListener); + } + + @Nullable AdTagLoader adTagLoader = adTagLoaderByAdsId.get(adsId); + if (adTagLoader == null) { + requestAds(adTagDataSpec, adsId, adViewProvider.getAdViewGroup()); + adTagLoader = adTagLoaderByAdsId.get(adsId); + } + adTagLoaderByAdsMediaSource.put(adsMediaSource, checkNotNull(adTagLoader)); + adTagLoader.addListenerWithAdView(eventListener, adViewProvider); + maybeUpdateCurrentAdTagLoader(); + } + + @Override + public void stop(AdsMediaSource adsMediaSource, EventListener eventListener) { + @Nullable AdTagLoader removedAdTagLoader = adTagLoaderByAdsMediaSource.remove(adsMediaSource); + maybeUpdateCurrentAdTagLoader(); + if (removedAdTagLoader != null) { + removedAdTagLoader.removeListener(eventListener); + } + + if (player != null && adTagLoaderByAdsMediaSource.isEmpty()) { + player.removeListener(playerListener); + player = null; + } + } + + @Override + public void release() { + if (player != null) { + player.removeListener(playerListener); + player = null; + maybeUpdateCurrentAdTagLoader(); + } + nextPlayer = null; + + for (AdTagLoader adTagLoader : adTagLoaderByAdsMediaSource.values()) { + adTagLoader.release(); + } + adTagLoaderByAdsMediaSource.clear(); + + for (AdTagLoader adTagLoader : adTagLoaderByAdsId.values()) { + adTagLoader.release(); + } + adTagLoaderByAdsId.clear(); + } + + @Override + public void handlePrepareComplete( + AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup) { + if (player == null) { + return; + } + checkNotNull(adTagLoaderByAdsMediaSource.get(adsMediaSource)) + .handlePrepareComplete(adGroupIndex, adIndexInAdGroup); + } + + @Override + public void handlePrepareError( + AdsMediaSource adsMediaSource, + int adGroupIndex, + int adIndexInAdGroup, + IOException exception) { + if (player == null) { + return; + } + checkNotNull(adTagLoaderByAdsMediaSource.get(adsMediaSource)) + .handlePrepareError(adGroupIndex, adIndexInAdGroup, exception); + } + + // Internal methods. + + private void maybeUpdateCurrentAdTagLoader() { + @Nullable AdTagLoader oldAdTagLoader = currentAdTagLoader; + @Nullable AdTagLoader newAdTagLoader = getCurrentAdTagLoader(); + if (!Util.areEqual(oldAdTagLoader, newAdTagLoader)) { + if (oldAdTagLoader != null) { + oldAdTagLoader.deactivate(); + } + currentAdTagLoader = newAdTagLoader; + if (newAdTagLoader != null) { + newAdTagLoader.activate(checkNotNull(player)); + } + } + } + + @Nullable + private AdTagLoader getCurrentAdTagLoader() { + @Nullable Player player = this.player; + if (player == null) { + return null; + } + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + return null; + } + int periodIndex = player.getCurrentPeriodIndex(); + @Nullable Object adsId = timeline.getPeriod(periodIndex, period).getAdsId(); + if (adsId == null) { + return null; + } + @Nullable AdTagLoader adTagLoader = adTagLoaderByAdsId.get(adsId); + if (adTagLoader == null || !adTagLoaderByAdsMediaSource.containsValue(adTagLoader)) { + return null; + } + return adTagLoader; + } + + private void maybePreloadNextPeriodAds() { + @Nullable Player player = ImaAdsLoader.this.player; + if (player == null) { + return; + } + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + return; + } + int nextPeriodIndex = + timeline.getNextPeriodIndex( + player.getCurrentPeriodIndex(), + period, + window, + player.getRepeatMode(), + player.getShuffleModeEnabled()); + if (nextPeriodIndex == C.INDEX_UNSET) { + return; + } + timeline.getPeriod(nextPeriodIndex, period); + @Nullable Object nextAdsId = period.getAdsId(); + if (nextAdsId == null) { + return; + } + @Nullable AdTagLoader nextAdTagLoader = adTagLoaderByAdsId.get(nextAdsId); + if (nextAdTagLoader == null || nextAdTagLoader == currentAdTagLoader) { + return; + } + long periodPositionUs = + timeline.getPeriodPositionUs( + window, period, period.windowIndex, /* windowPositionUs= */ C.TIME_UNSET) + .second; + nextAdTagLoader.maybePreloadAds(Util.usToMs(periodPositionUs), Util.usToMs(period.durationUs)); + } + + private final class PlayerListenerImpl implements Player.Listener { + + @Override + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + if (timeline.isEmpty()) { + // The player is being reset or contains no media. + return; + } + maybeUpdateCurrentAdTagLoader(); + maybePreloadNextPeriodAds(); + } + + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + maybeUpdateCurrentAdTagLoader(); + maybePreloadNextPeriodAds(); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + maybePreloadNextPeriodAds(); + } + + @Override + public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) { + maybePreloadNextPeriodAds(); + } + } + + /** + * Default {@link ImaUtil.ImaFactory} for non-test usage, which delegates to {@link + * ImaSdkFactory}. + */ + private static final class DefaultImaFactory implements ImaUtil.ImaFactory { + @Override + public ImaSdkSettings createImaSdkSettings() { + ImaSdkSettings settings = ImaSdkFactory.getInstance().createImaSdkSettings(); + settings.setLanguage(Util.getSystemLanguageCodes()[0]); + return settings; + } + + @Override + public AdsRenderingSettings createAdsRenderingSettings() { + return ImaSdkFactory.getInstance().createAdsRenderingSettings(); + } + + @Override + public AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player) { + return ImaSdkFactory.createAdDisplayContainer(container, player); + } + + @Override + public AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player) { + return ImaSdkFactory.createAudioAdDisplayContainer(context, player); + } + + // The reasonDetail parameter to createFriendlyObstruction is annotated @Nullable but the + // annotation is not kept in the obfuscated dependency. + @SuppressWarnings("nullness:argument") + @Override + public FriendlyObstruction createFriendlyObstruction( + View view, + FriendlyObstructionPurpose friendlyObstructionPurpose, + @Nullable String reasonDetail) { + return ImaSdkFactory.getInstance() + .createFriendlyObstruction(view, friendlyObstructionPurpose, reasonDetail); + } + + @Override + public AdsRequest createAdsRequest() { + return ImaSdkFactory.getInstance().createAdsRequest(); + } + + @Override + public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { + return ImaSdkFactory.getInstance() + .createAdsLoader(context, imaSdkSettings, adDisplayContainer); + } + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaServerSideAdInsertionMediaSource.java b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaServerSideAdInsertionMediaSource.java new file mode 100644 index 0000000000..2bb6433654 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaServerSideAdInsertionMediaSource.java @@ -0,0 +1,1320 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.brentvatne.exoplayer.ext.ima; + +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.expandAdGroupPlaceholder; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.getAdGroupAndIndexInMultiPeriodWindow; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.secToMsRounded; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.secToUsRounded; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.splitAdPlaybackStateForPeriods; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.updateAdDurationAndPropagate; +import static com.brentvatne.exoplayer.ext.ima.ImaUtil.updateAdDurationInAdGroup; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.msToUs; +import static com.google.android.exoplayer2.util.Util.sum; +import static com.google.android.exoplayer2.util.Util.usToMs; +import static java.lang.Math.min; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.util.Pair; +import android.view.ViewGroup; +import androidx.annotation.IntDef; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.ads.interactivemedia.v3.api.Ad; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; +import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener; +import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; +import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; +import com.google.ads.interactivemedia.v3.api.CuePoint; +import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.StreamDisplayContainer; +import com.google.ads.interactivemedia.v3.api.StreamManager; +import com.google.ads.interactivemedia.v3.api.StreamRequest; +import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; +import com.google.ads.interactivemedia.v3.api.player.VideoStreamPlayer; +import com.google.android.exoplayer2.Bundleable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.drm.DrmSessionManagerProvider; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.emsg.EventMessage; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; +import com.google.android.exoplayer2.source.CompositeMediaSource; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.source.ForwardingTimeline; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.source.ads.ServerSideAdInsertionMediaSource; +import com.google.android.exoplayer2.source.ads.ServerSideAdInsertionMediaSource.AdPlaybackStateUpdater; +import com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil; +import com.google.android.exoplayer2.ui.AdOverlayInfo; +import com.google.android.exoplayer2.ui.AdViewProvider; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import com.google.android.exoplayer2.upstream.Loader.Loadable; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** MediaSource for IMA server side inserted ad streams. */ +public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSource { + + /** + * Factory for creating {@link ImaServerSideAdInsertionMediaSource + * ImaServerSideAdInsertionMediaSources}. + * + *

Apps can use the {@link ImaServerSideAdInsertionMediaSource.Factory} to customized the + * {@link DefaultMediaSourceFactory} that is used to build a player: + */ + public static final class Factory implements MediaSource.Factory { + + private final AdsLoader adsLoader; + private final MediaSource.Factory contentMediaSourceFactory; + + /** + * Creates a new factory for {@link ImaServerSideAdInsertionMediaSource + * ImaServerSideAdInsertionMediaSources}. + * + * @param adsLoader The {@link AdsLoader}. + * @param contentMediaSourceFactory The content media source factory to create content sources. + */ + public Factory(AdsLoader adsLoader, MediaSource.Factory contentMediaSourceFactory) { + this.adsLoader = adsLoader; + this.contentMediaSourceFactory = contentMediaSourceFactory; + } + + @Override + public MediaSource.Factory setLoadErrorHandlingPolicy( + LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + contentMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); + return this; + } + + @Override + public MediaSource.Factory setDrmSessionManagerProvider( + DrmSessionManagerProvider drmSessionManagerProvider) { + contentMediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider); + return this; + } + + @Override + public int[] getSupportedTypes() { + return contentMediaSourceFactory.getSupportedTypes(); + } + + @Override + public MediaSource createMediaSource(MediaItem mediaItem) { + checkNotNull(mediaItem.localConfiguration); + Player player = checkNotNull(adsLoader.player); + StreamPlayer streamPlayer = new StreamPlayer(player, mediaItem); + ImaSdkFactory imaSdkFactory = ImaSdkFactory.getInstance(); + StreamDisplayContainer streamDisplayContainer = + createStreamDisplayContainer(imaSdkFactory, adsLoader.configuration, streamPlayer); + com.google.ads.interactivemedia.v3.api.AdsLoader imaAdsLoader = + imaSdkFactory.createAdsLoader( + adsLoader.context, adsLoader.configuration.imaSdkSettings, streamDisplayContainer); + ImaServerSideAdInsertionMediaSource mediaSource = + new ImaServerSideAdInsertionMediaSource( + mediaItem, + player, + adsLoader, + imaAdsLoader, + streamPlayer, + contentMediaSourceFactory, + adsLoader.configuration.applicationAdEventListener, + adsLoader.configuration.applicationAdErrorListener); + adsLoader.addMediaSourceResources(mediaSource, streamPlayer, imaAdsLoader); + return mediaSource; + } + } + + /** An ads loader for IMA server side ad insertion streams. */ + public static final class AdsLoader { + + /** Builder for building an {@link AdsLoader}. */ + public static final class Builder { + + private final Context context; + private final AdViewProvider adViewProvider; + + @Nullable private ImaSdkSettings imaSdkSettings; + @Nullable private AdEventListener adEventListener; + @Nullable private AdErrorEvent.AdErrorListener adErrorListener; + private State state; + private ImmutableList companionAdSlots; + + /** + * Creates an instance. + * + * @param context A context. + * @param adViewProvider A provider for {@link ViewGroup} instances. + */ + public Builder(Context context, AdViewProvider adViewProvider) { + this.context = context; + this.adViewProvider = adViewProvider; + companionAdSlots = ImmutableList.of(); + state = new State(ImmutableMap.of()); + } + + /** + * Sets the IMA SDK settings. + * + *

If this method is not called the default settings will be used. + * + * @param imaSdkSettings The {@link ImaSdkSettings}. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { + this.imaSdkSettings = imaSdkSettings; + return this; + } + + /** + * Sets the optional {@link AdEventListener} that will be passed to {@link + * AdsManager#addAdEventListener(AdEventListener)}. + * + * @param adEventListener The ad event listener. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setAdEventListener(AdEventListener adEventListener) { + this.adEventListener = adEventListener; + return this; + } + + /** + * Sets the optional {@link AdErrorEvent.AdErrorListener} that will be passed to {@link + * AdsManager#addAdErrorListener(AdErrorEvent.AdErrorListener)}. + * + * @param adErrorListener The {@link AdErrorEvent.AdErrorListener}. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setAdErrorListener(AdErrorEvent.AdErrorListener adErrorListener) { + this.adErrorListener = adErrorListener; + return this; + } + + /** + * Sets the slots to use for companion ads, if they are present in the loaded ad. + * + * @param companionAdSlots The slots to use for companion ads. + * @return This builder, for convenience. + * @see AdDisplayContainer#setCompanionSlots(Collection) + */ + public AdsLoader.Builder setCompanionAdSlots(Collection companionAdSlots) { + this.companionAdSlots = ImmutableList.copyOf(companionAdSlots); + return this; + } + + /** + * Sets the optional state to resume with. + * + *

The state can be received when {@link #release() releasing} the {@link AdsLoader}. + * + * @param state The state to resume with. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setAdsLoaderState(State state) { + this.state = state; + return this; + } + + /** Returns a new {@link AdsLoader}. */ + public AdsLoader build() { + @Nullable ImaSdkSettings imaSdkSettings = this.imaSdkSettings; + if (imaSdkSettings == null) { + imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings(); + imaSdkSettings.setLanguage(Util.getSystemLanguageCodes()[0]); + } + ImaUtil.ServerSideAdInsertionConfiguration configuration = + new ImaUtil.ServerSideAdInsertionConfiguration( + adViewProvider, + imaSdkSettings, + adEventListener, + adErrorListener, + companionAdSlots, + imaSdkSettings.isDebugMode()); + return new AdsLoader(context, configuration, state); + } + } + + /** The state of the {@link AdsLoader} that can be used when resuming from the background. */ + public static class State implements Bundleable { + + private final ImmutableMap adPlaybackStates; + + @VisibleForTesting + /* package */ State(ImmutableMap adPlaybackStates) { + this.adPlaybackStates = adPlaybackStates; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof State)) { + return false; + } + State state = (State) o; + return adPlaybackStates.equals(state.adPlaybackStates); + } + + @Override + public int hashCode() { + return adPlaybackStates.hashCode(); + } + + // Bundleable implementation. + + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({FIELD_AD_PLAYBACK_STATES}) + private @interface FieldNumber {} + + private static final int FIELD_AD_PLAYBACK_STATES = 1; + + @Override + public Bundle toBundle() { + Bundle bundle = new Bundle(); + Bundle adPlaybackStatesBundle = new Bundle(); + for (Map.Entry entry : adPlaybackStates.entrySet()) { + adPlaybackStatesBundle.putBundle(entry.getKey(), entry.getValue().toBundle()); + } + bundle.putBundle(keyForField(FIELD_AD_PLAYBACK_STATES), adPlaybackStatesBundle); + return bundle; + } + + /** Object that can restore {@link AdsLoader.State} from a {@link Bundle}. */ + public static final Bundleable.Creator CREATOR = State::fromBundle; + + private static State fromBundle(Bundle bundle) { + @Nullable + ImmutableMap.Builder adPlaybackStateMap = + new ImmutableMap.Builder<>(); + Bundle adPlaybackStateBundle = + checkNotNull(bundle.getBundle(keyForField(FIELD_AD_PLAYBACK_STATES))); + for (String key : adPlaybackStateBundle.keySet()) { + AdPlaybackState adPlaybackState = + AdPlaybackState.CREATOR.fromBundle( + checkNotNull(adPlaybackStateBundle.getBundle(key))); + adPlaybackStateMap.put( + key, AdPlaybackState.fromAdPlaybackState(/* adsId= */ key, adPlaybackState)); + } + return new State(adPlaybackStateMap.buildOrThrow()); + } + + private static String keyForField(@FieldNumber int field) { + return Integer.toString(field, Character.MAX_RADIX); + } + } + + private final ImaUtil.ServerSideAdInsertionConfiguration configuration; + private final Context context; + private final Map + mediaSourceResources; + private final Map adPlaybackStateMap; + + @Nullable private Player player; + + private AdsLoader( + Context context, ImaUtil.ServerSideAdInsertionConfiguration configuration, State state) { + this.context = context.getApplicationContext(); + this.configuration = configuration; + mediaSourceResources = new HashMap<>(); + adPlaybackStateMap = new HashMap<>(); + for (Map.Entry entry : state.adPlaybackStates.entrySet()) { + adPlaybackStateMap.put(entry.getKey(), entry.getValue()); + } + } + + /** + * Sets the player. + * + *

This method needs to be called before adding server side ad insertion media items to the + * player. + */ + public void setPlayer(Player player) { + this.player = player; + } + + /** + * Releases resources. + * + * @return The {@link State} that can be used when resuming from the background. + */ + public State release() { + for (MediaSourceResourceHolder resourceHolder : mediaSourceResources.values()) { + resourceHolder.streamPlayer.release(); + resourceHolder.adsLoader.release(); + resourceHolder.imaServerSideAdInsertionMediaSource.setStreamManager( + /* streamManager= */ null); + } + State state = new State(ImmutableMap.copyOf(adPlaybackStateMap)); + adPlaybackStateMap.clear(); + mediaSourceResources.clear(); + player = null; + return state; + } + + // Internal methods. + + private void addMediaSourceResources( + ImaServerSideAdInsertionMediaSource mediaSource, + StreamPlayer streamPlayer, + com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { + mediaSourceResources.put( + mediaSource, new MediaSourceResourceHolder(mediaSource, streamPlayer, adsLoader)); + } + + private AdPlaybackState getAdPlaybackState(String adsId) { + @Nullable AdPlaybackState adPlaybackState = adPlaybackStateMap.get(adsId); + return adPlaybackState != null ? adPlaybackState : AdPlaybackState.NONE; + } + + private void setAdPlaybackState(String adsId, AdPlaybackState adPlaybackState) { + this.adPlaybackStateMap.put(adsId, adPlaybackState); + } + + private static final class MediaSourceResourceHolder { + public final ImaServerSideAdInsertionMediaSource imaServerSideAdInsertionMediaSource; + public final StreamPlayer streamPlayer; + public final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + + private MediaSourceResourceHolder( + ImaServerSideAdInsertionMediaSource imaServerSideAdInsertionMediaSource, + StreamPlayer streamPlayer, + com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { + this.imaServerSideAdInsertionMediaSource = imaServerSideAdInsertionMediaSource; + this.streamPlayer = streamPlayer; + this.adsLoader = adsLoader; + } + } + } + + private final MediaItem mediaItem; + private final Player player; + private final MediaSource.Factory contentMediaSourceFactory; + private final AdsLoader adsLoader; + private final com.google.ads.interactivemedia.v3.api.AdsLoader sdkAdsLoader; + @Nullable private final AdEventListener applicationAdEventListener; + @Nullable private final AdErrorListener applicationAdErrorListener; + private final boolean isLiveStream; + private final String adsId; + private final StreamRequest streamRequest; + private final int loadVideoTimeoutMs; + private final StreamPlayer streamPlayer; + private final Handler mainHandler; + private final ComponentListener componentListener; + + @Nullable private Loader loader; + @Nullable private StreamManager streamManager; + @Nullable private ServerSideAdInsertionMediaSource serverSideAdInsertionMediaSource; + @Nullable private IOException loadError; + private @MonotonicNonNull Timeline contentTimeline; + private AdPlaybackState adPlaybackState; + private int firstSeenAdIndexInAdGroup; + + private ImaServerSideAdInsertionMediaSource( + MediaItem mediaItem, + Player player, + AdsLoader adsLoader, + com.google.ads.interactivemedia.v3.api.AdsLoader sdkAdsLoader, + StreamPlayer streamPlayer, + MediaSource.Factory contentMediaSourceFactory, + @Nullable AdEventListener applicationAdEventListener, + @Nullable AdErrorEvent.AdErrorListener applicationAdErrorListener) { + this.mediaItem = mediaItem; + this.player = player; + this.adsLoader = adsLoader; + this.sdkAdsLoader = sdkAdsLoader; + this.streamPlayer = streamPlayer; + this.contentMediaSourceFactory = contentMediaSourceFactory; + this.applicationAdEventListener = applicationAdEventListener; + this.applicationAdErrorListener = applicationAdErrorListener; + componentListener = new ComponentListener(); + mainHandler = Util.createHandlerForCurrentLooper(); + Uri streamRequestUri = checkNotNull(mediaItem.localConfiguration).uri; + isLiveStream = ImaServerSideAdInsertionUriBuilder.isLiveStream(streamRequestUri); + adsId = ImaServerSideAdInsertionUriBuilder.getAdsId(streamRequestUri); + loadVideoTimeoutMs = ImaServerSideAdInsertionUriBuilder.getLoadVideoTimeoutMs(streamRequestUri); + streamRequest = ImaServerSideAdInsertionUriBuilder.createStreamRequest(streamRequestUri); + adPlaybackState = adsLoader.getAdPlaybackState(adsId); + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + mainHandler.post(() -> assertSingleInstanceInPlaylist(checkNotNull(player))); + super.prepareSourceInternal(mediaTransferListener); + if (loader == null) { + Loader loader = new Loader("ImaServerSideAdInsertionMediaSource"); + player.addListener(componentListener); + StreamManagerLoadable streamManagerLoadable = + new StreamManagerLoadable( + sdkAdsLoader, + streamRequest, + streamPlayer, + applicationAdErrorListener, + loadVideoTimeoutMs); + loader.startLoading( + streamManagerLoadable, + new StreamManagerLoadableCallback(), + /* defaultMinRetryCount= */ 0); + this.loader = loader; + } + } + + @Override + protected void onChildSourceInfoRefreshed( + Void id, MediaSource mediaSource, Timeline newTimeline) { + refreshSourceInfo( + new ForwardingTimeline(newTimeline) { + @Override + public Window getWindow( + int windowIndex, Window window, long defaultPositionProjectionUs) { + newTimeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + window.mediaItem = mediaItem; + return window; + } + }); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return checkNotNull(serverSideAdInsertionMediaSource) + .createPeriod(id, allocator, startPositionUs); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + checkNotNull(serverSideAdInsertionMediaSource).releasePeriod(mediaPeriod); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + super.maybeThrowSourceInfoRefreshError(); + if (loadError != null) { + IOException loadError = this.loadError; + this.loadError = null; + throw loadError; + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + if (loader != null) { + loader.release(); + player.removeListener(componentListener); + mainHandler.post(() -> setStreamManager(/* streamManager= */ null)); + loader = null; + } + } + + // Internal methods (called on the main thread). + + @MainThread + private void setStreamManager(@Nullable StreamManager streamManager) { + if (this.streamManager == streamManager) { + return; + } + if (this.streamManager != null) { + if (applicationAdEventListener != null) { + this.streamManager.removeAdEventListener(applicationAdEventListener); + } + if (applicationAdErrorListener != null) { + this.streamManager.removeAdErrorListener(applicationAdErrorListener); + } + this.streamManager.removeAdEventListener(componentListener); + this.streamManager.destroy(); + this.streamManager = null; + } + this.streamManager = streamManager; + if (streamManager != null) { + streamManager.addAdEventListener(componentListener); + if (applicationAdEventListener != null) { + streamManager.addAdEventListener(applicationAdEventListener); + } + if (applicationAdErrorListener != null) { + streamManager.addAdErrorListener(applicationAdErrorListener); + } + } + } + + @MainThread + private void setAdPlaybackState(AdPlaybackState adPlaybackState) { + if (adPlaybackState.equals(this.adPlaybackState)) { + return; + } + this.adPlaybackState = adPlaybackState; + invalidateServerSideAdInsertionAdPlaybackState(); + } + + @MainThread + @EnsuresNonNull("this.contentTimeline") + private void setContentTimeline(Timeline contentTimeline) { + if (contentTimeline.equals(this.contentTimeline)) { + return; + } + this.contentTimeline = contentTimeline; + invalidateServerSideAdInsertionAdPlaybackState(); + } + + @MainThread + private void invalidateServerSideAdInsertionAdPlaybackState() { + if (!adPlaybackState.equals(AdPlaybackState.NONE) && contentTimeline != null) { + ImmutableMap splitAdPlaybackStates = + splitAdPlaybackStateForPeriods(adPlaybackState, contentTimeline); + streamPlayer.setAdPlaybackStates(adsId, splitAdPlaybackStates, contentTimeline); + checkNotNull(serverSideAdInsertionMediaSource).setAdPlaybackStates(splitAdPlaybackStates); + if (!ImaServerSideAdInsertionUriBuilder.isLiveStream( + checkNotNull(mediaItem.localConfiguration).uri)) { + adsLoader.setAdPlaybackState(adsId, adPlaybackState); + } + } + } + + // Internal methods (called on the playback thread). + + private void setContentUri(Uri contentUri) { + if (serverSideAdInsertionMediaSource != null) { + return; + } + MediaItem contentMediaItem = + new MediaItem.Builder() + .setUri(contentUri) + .setDrmConfiguration(checkNotNull(mediaItem.localConfiguration).drmConfiguration) + .setLiveConfiguration(mediaItem.liveConfiguration) + .setCustomCacheKey(mediaItem.localConfiguration.customCacheKey) + .setStreamKeys(mediaItem.localConfiguration.streamKeys) + .build(); + ServerSideAdInsertionMediaSource serverSideAdInsertionMediaSource = + new ServerSideAdInsertionMediaSource( + contentMediaSourceFactory.createMediaSource(contentMediaItem), componentListener); + this.serverSideAdInsertionMediaSource = serverSideAdInsertionMediaSource; + if (isLiveStream) { + AdPlaybackState liveAdPlaybackState = + new AdPlaybackState(adsId) + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + mainHandler.post(() -> setAdPlaybackState(liveAdPlaybackState)); + } + prepareChildSource(/* id= */ null, serverSideAdInsertionMediaSource); + } + + // Static methods. + + private static AdPlaybackState setVodAdGroupPlaceholders( + List cuePoints, AdPlaybackState adPlaybackState) { + // TODO(b/192231683) Use getEndTimeMs()/getStartTimeMs() after jar target was removed + for (int i = 0; i < cuePoints.size(); i++) { + CuePoint cuePoint = cuePoints.get(i); + long fromPositionUs = msToUs(secToMsRounded(cuePoint.getStartTime())); + adPlaybackState = + addAdGroupToAdPlaybackState( + adPlaybackState, + /* fromPositionUs= */ fromPositionUs, + /* contentResumeOffsetUs= */ 0, + /* adDurationsUs...= */ getAdDuration( + /* startTimeSeconds= */ cuePoint.getStartTime(), + /* endTimeSeconds= */ cuePoint.getEndTime())); + } + return adPlaybackState; + } + + private static long getAdDuration(double startTimeSeconds, double endTimeSeconds) { + // startTimeSeconds and endTimeSeconds that are coming from the SDK, only have a precision of + // milliseconds so everything that is below a millisecond can be safely considered as coming + // from rounding issues. + return msToUs(secToMsRounded(endTimeSeconds - startTimeSeconds)); + } + + private static AdPlaybackState setVodAdInPlaceholder(Ad ad, AdPlaybackState adPlaybackState) { + AdPodInfo adPodInfo = ad.getAdPodInfo(); + // Handle post rolls that have a podIndex of -1. + int adGroupIndex = + adPodInfo.getPodIndex() == -1 ? adPlaybackState.adGroupCount - 1 : adPodInfo.getPodIndex(); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + if (adGroup.count < adPodInfo.getTotalAds()) { + adPlaybackState = + expandAdGroupPlaceholder( + adGroupIndex, + /* adGroupDurationUs= */ msToUs(secToMsRounded(adPodInfo.getMaxDuration())), + adIndexInAdGroup, + /* adDurationUs= */ msToUs(secToMsRounded(ad.getDuration())), + /* adsInAdGroupCount= */ adPodInfo.getTotalAds(), + adPlaybackState); + } else if (adIndexInAdGroup < adGroup.count - 1) { + adPlaybackState = + updateAdDurationInAdGroup( + adGroupIndex, + adIndexInAdGroup, + /* adDurationUs= */ msToUs(secToMsRounded(ad.getDuration())), + adPlaybackState); + } + return adPlaybackState; + } + + private AdPlaybackState addLiveAdBreak( + Ad ad, long currentPeriodPositionUs, AdPlaybackState adPlaybackState) { + AdPodInfo adPodInfo = ad.getAdPodInfo(); + long adDurationUs = secToUsRounded(ad.getDuration()); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + // TODO(b/208398934) Support seeking backwards. + if (adIndexInAdGroup == 0 || adPlaybackState.adGroupCount == 1) { + firstSeenAdIndexInAdGroup = adIndexInAdGroup; + // Adjust count and ad index in case we joined the live stream within an ad group. + int adCount = adPodInfo.getTotalAds() - firstSeenAdIndexInAdGroup; + adIndexInAdGroup -= firstSeenAdIndexInAdGroup; + // First ad of group. Create a new group with all ads. + long[] adDurationsUs = + updateAdDurationAndPropagate( + new long[adCount], + adIndexInAdGroup, + adDurationUs, + msToUs(secToMsRounded(adPodInfo.getMaxDuration()))); + adPlaybackState = + addAdGroupToAdPlaybackState( + adPlaybackState, + /* fromPositionUs= */ currentPeriodPositionUs, + /* contentResumeOffsetUs= */ sum(adDurationsUs), + /* adDurationsUs...= */ adDurationsUs); + } else { + int adGroupIndex = adPlaybackState.adGroupCount - 2; + adIndexInAdGroup -= firstSeenAdIndexInAdGroup; + if (adPodInfo.getTotalAds() == adPodInfo.getAdPosition()) { + // Reset the ad index whe we are at the last ad in the group. + firstSeenAdIndexInAdGroup = 0; + } + adPlaybackState = + updateAdDurationInAdGroup(adGroupIndex, adIndexInAdGroup, adDurationUs, adPlaybackState); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + return adPlaybackState.withContentResumeOffsetUs( + adGroupIndex, min(adGroup.contentResumeOffsetUs, sum(adGroup.durationsUs))); + } + return adPlaybackState; + } + + private static AdPlaybackState skipAd(Ad ad, AdPlaybackState adPlaybackState) { + AdPodInfo adPodInfo = ad.getAdPodInfo(); + int adGroupIndex = adPodInfo.getPodIndex(); + // IMA SDK always returns index starting at 1. + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + return adPlaybackState.withSkippedAd(adGroupIndex, adIndexInAdGroup); + } + + private final class ComponentListener + implements AdEvent.AdEventListener, Player.Listener, AdPlaybackStateUpdater { + + // Implement Player.Listener. + + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (reason != Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { + // Only auto transitions within the same or to the next media item are of interest. + return; + } + + if (mediaItem.equals(oldPosition.mediaItem) && !mediaItem.equals(newPosition.mediaItem)) { + // Playback automatically transitioned to the next media item. Notify the SDK. + streamPlayer.onContentCompleted(); + } + + if (!mediaItem.equals(oldPosition.mediaItem) + || !mediaItem.equals(newPosition.mediaItem) + || !adsId.equals( + player + .getCurrentTimeline() + .getPeriodByUid(checkNotNull(newPosition.periodUid), new Timeline.Period()) + .getAdsId())) { + // Discontinuity not within this ad media source. + return; + } + + if (oldPosition.adGroupIndex != C.INDEX_UNSET) { + int adGroupIndex = oldPosition.adGroupIndex; + int adIndexInAdGroup = oldPosition.adIndexInAdGroup; + Timeline timeline = player.getCurrentTimeline(); + Timeline.Window window = + timeline.getWindow(oldPosition.mediaItemIndex, new Timeline.Window()); + if (window.lastPeriodIndex > window.firstPeriodIndex) { + // Map adGroupIndex and adIndexInAdGroup to multi-period window. + Pair adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow( + oldPosition.periodIndex - window.firstPeriodIndex, + adPlaybackState, + checkNotNull(contentTimeline)); + adGroupIndex = adGroupIndexAndAdIndexInAdGroup.first; + adIndexInAdGroup = adGroupIndexAndAdIndexInAdGroup.second; + } + int adState = adPlaybackState.getAdGroup(adGroupIndex).states[adIndexInAdGroup]; + if (adState == AdPlaybackState.AD_STATE_AVAILABLE + || adState == AdPlaybackState.AD_STATE_UNAVAILABLE) { + setAdPlaybackState( + adPlaybackState.withPlayedAd(adGroupIndex, /* adIndexInAdGroup= */ adIndexInAdGroup)); + } + } + } + + @Override + public void onMetadata(Metadata metadata) { + if (!isCurrentAdPlaying(player, mediaItem, adsId)) { + return; + } + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TextInformationFrame) { + TextInformationFrame textFrame = (TextInformationFrame) entry; + if ("TXXX".equals(textFrame.id)) { + streamPlayer.triggerUserTextReceived(textFrame.value); + } + } else if (entry instanceof EventMessage) { + EventMessage eventMessage = (EventMessage) entry; + String eventMessageValue = new String(eventMessage.messageData); + streamPlayer.triggerUserTextReceived(eventMessageValue); + } + } + } + + @Override + public void onPlaybackStateChanged(@Player.State int state) { + if (state == Player.STATE_ENDED && isCurrentAdPlaying(player, mediaItem, adsId)) { + streamPlayer.onContentCompleted(); + } + } + + @Override + public void onVolumeChanged(float volume) { + if (!isCurrentAdPlaying(player, mediaItem, adsId)) { + return; + } + int volumePct = (int) Math.floor(volume * 100); + streamPlayer.onContentVolumeChanged(volumePct); + } + + // Implement AdEvent.AdEventListener. + + @MainThread + @Override + public void onAdEvent(AdEvent event) { + AdPlaybackState newAdPlaybackState = adPlaybackState; + switch (event.getType()) { + case CUEPOINTS_CHANGED: + // CUEPOINTS_CHANGED event is firing multiple times with the same queue points. + if (!isLiveStream && newAdPlaybackState.equals(AdPlaybackState.NONE)) { + newAdPlaybackState = + setVodAdGroupPlaceholders( + checkNotNull(streamManager).getCuePoints(), new AdPlaybackState(adsId)); + } + break; + case LOADED: + if (isLiveStream) { + Timeline timeline = player.getCurrentTimeline(); + Timeline.Window window = + timeline.getWindow(player.getCurrentMediaItemIndex(), new Timeline.Window()); + if (window.lastPeriodIndex > window.firstPeriodIndex) { + // multi-period live not integrated + return; + } + long positionInWindowUs = + timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period()) + .positionInWindowUs; + long currentPeriodPosition = msToUs(player.getContentPosition()) - positionInWindowUs; + newAdPlaybackState = + addLiveAdBreak( + event.getAd(), + currentPeriodPosition, + newAdPlaybackState.equals(AdPlaybackState.NONE) + ? new AdPlaybackState(adsId) + : newAdPlaybackState); + } else { + newAdPlaybackState = setVodAdInPlaceholder(event.getAd(), newAdPlaybackState); + } + break; + case SKIPPED: + if (!isLiveStream) { + newAdPlaybackState = skipAd(event.getAd(), newAdPlaybackState); + } + break; + default: + // Do nothing. + break; + } + setAdPlaybackState(newAdPlaybackState); + } + + // Implement AdPlaybackStateUpdater (called on the playback thread). + + @Override + public boolean onAdPlaybackStateUpdateRequested(Timeline contentTimeline) { + mainHandler.post(() -> setContentTimeline(contentTimeline)); + // Defer source refresh to ad playback state update for VOD. Refresh immediately when live + // with single period. + return !isLiveStream || contentTimeline.getPeriodCount() > 1; + } + } + + private final class StreamManagerLoadableCallback + implements Loader.Callback { + + @Override + public void onLoadCompleted( + StreamManagerLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { + mainHandler.post(() -> setStreamManager(checkNotNull(loadable.getStreamManager()))); + setContentUri(checkNotNull(loadable.getContentUri())); + } + + @Override + public void onLoadCanceled( + StreamManagerLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + // We only cancel when the loader is released. + checkState(released); + } + + @Override + public LoadErrorAction onLoadError( + StreamManagerLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + loadError = error; + return Loader.DONT_RETRY; + } + } + + /** Loads the {@link StreamManager} and the content URI. */ + private static class StreamManagerLoadable + implements Loadable, AdsLoadedListener, AdErrorListener { + + private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final StreamRequest request; + private final StreamPlayer streamPlayer; + @Nullable private final AdErrorListener adErrorListener; + private final int loadVideoTimeoutMs; + private final ConditionVariable conditionVariable; + + @Nullable private volatile StreamManager streamManager; + @Nullable private volatile Uri contentUri; + private volatile boolean cancelled; + private volatile boolean error; + @Nullable private volatile String errorMessage; + private volatile int errorCode; + + /** Creates an instance. */ + private StreamManagerLoadable( + com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader, + StreamRequest request, + StreamPlayer streamPlayer, + @Nullable AdErrorListener adErrorListener, + int loadVideoTimeoutMs) { + this.adsLoader = adsLoader; + this.request = request; + this.streamPlayer = streamPlayer; + this.adErrorListener = adErrorListener; + this.loadVideoTimeoutMs = loadVideoTimeoutMs; + conditionVariable = new ConditionVariable(); + errorCode = -1; + } + + /** Returns the DAI content URI or null if not yet available. */ + @Nullable + public Uri getContentUri() { + return contentUri; + } + + /** Returns the stream manager or null if not yet loaded. */ + @Nullable + public StreamManager getStreamManager() { + return streamManager; + } + + // Implement Loadable. + + @Override + public void load() throws IOException { + try { + // SDK will call loadUrl on stream player for SDK once manifest uri is available. + streamPlayer.setStreamLoadListener( + (streamUri, subtitles) -> { + contentUri = Uri.parse(streamUri); + conditionVariable.open(); + }); + if (adErrorListener != null) { + adsLoader.addAdErrorListener(adErrorListener); + } + adsLoader.addAdsLoadedListener(this); + adsLoader.addAdErrorListener(this); + adsLoader.requestStream(request); + while (contentUri == null && !cancelled && !error) { + try { + conditionVariable.block(); + } catch (InterruptedException e) { + /* Do nothing. */ + } + } + if (error && contentUri == null) { + throw new IOException(errorMessage + " [errorCode: " + errorCode + "]"); + } + } finally { + adsLoader.removeAdsLoadedListener(this); + adsLoader.removeAdErrorListener(this); + if (adErrorListener != null) { + adsLoader.removeAdErrorListener(adErrorListener); + } + } + } + + @Override + public void cancelLoad() { + cancelled = true; + } + + // AdsLoader.AdsLoadedListener implementation. + + @MainThread + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent event) { + StreamManager streamManager = event.getStreamManager(); + if (streamManager == null) { + error = true; + errorMessage = "streamManager is null after ads manager has been loaded"; + conditionVariable.open(); + return; + } + AdsRenderingSettings adsRenderingSettings = + ImaSdkFactory.getInstance().createAdsRenderingSettings(); + adsRenderingSettings.setLoadVideoTimeout(loadVideoTimeoutMs); + // After initialization completed the streamUri will be reported to the streamPlayer. + streamManager.init(adsRenderingSettings); + this.streamManager = streamManager; + } + + // AdErrorEvent.AdErrorListener implementation. + + @MainThread + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + error = true; + if (adErrorEvent.getError() != null) { + @Nullable String errorMessage = adErrorEvent.getError().getMessage(); + if (errorMessage != null) { + this.errorMessage = errorMessage.replace('\n', ' '); + } + errorCode = adErrorEvent.getError().getErrorCodeNumber(); + } + conditionVariable.open(); + } + } + + /** + * Receives the content URI from the SDK and sends back in-band media metadata and playback + * progression data to the SDK. + */ + private static final class StreamPlayer implements VideoStreamPlayer { + + /** A listener to listen for the stream URI loaded by the SDK. */ + public interface StreamLoadListener { + /** + * Loads a stream with dynamic ad insertion given the stream url and subtitles array. The + * subtitles array is only used in VOD streams. + * + *

Each entry in the subtitles array is a HashMap that corresponds to a language. Each map + * will have a "language" key with a two letter language string value, a "language name" to + * specify the set of subtitles if multiple sets exist for the same language, and one or more + * subtitle key/value pairs. Here's an example the map for English: + * + *

"language" -> "en" "language_name" -> "English" "webvtt" -> + * "https://example.com/vtt/en.vtt" "ttml" -> "https://example.com/ttml/en.ttml" + */ + void onLoadStream(String streamUri, List> subtitles); + } + + private final List callbacks; + private final Player player; + private final MediaItem mediaItem; + private final Timeline.Window window; + private final Timeline.Period period; + + private ImmutableMap adPlaybackStates; + @Nullable private Timeline contentTimeline; + @Nullable private Object adsId; + @Nullable private StreamLoadListener streamLoadListener; + + /** Creates an instance. */ + public StreamPlayer(Player player, MediaItem mediaItem) { + this.player = player; + this.mediaItem = mediaItem; + callbacks = new ArrayList<>(/* initialCapacity= */ 1); + adPlaybackStates = ImmutableMap.of(); + window = new Timeline.Window(); + period = new Timeline.Period(); + } + + /** Registers the ad playback states matching to the given content timeline. */ + public void setAdPlaybackStates( + Object adsId, + ImmutableMap adPlaybackStates, + Timeline contentTimeline) { + this.adsId = adsId; + this.adPlaybackStates = adPlaybackStates; + this.contentTimeline = contentTimeline; + } + + /** Sets the {@link StreamLoadListener} to be called when the SSAI content URI was loaded. */ + public void setStreamLoadListener(StreamLoadListener listener) { + streamLoadListener = Assertions.checkNotNull(listener); + } + + /** Called when the content has completed playback. */ + public void onContentCompleted() { + for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) { + callback.onContentComplete(); + } + } + + /** Called when the content player changed the volume. */ + public void onContentVolumeChanged(int volumePct) { + for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) { + callback.onVolumeChanged(volumePct); + } + } + + /** Releases the player. */ + public void release() { + callbacks.clear(); + adsId = null; + adPlaybackStates = ImmutableMap.of(); + contentTimeline = null; + streamLoadListener = null; + } + + // Implements VolumeProvider. + + @Override + public int getVolume() { + return (int) Math.floor(player.getVolume() * 100); + } + + // Implement ContentProgressProvider. + + @Override + public VideoProgressUpdate getContentProgress() { + if (!isCurrentAdPlaying(player, mediaItem, adsId)) { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } else if (adPlaybackStates.isEmpty()) { + return new VideoProgressUpdate(/* currentTimeMs= */ 0, /* durationMs= */ C.TIME_UNSET); + } + + Timeline timeline = player.getCurrentTimeline(); + int currentPeriodIndex = player.getCurrentPeriodIndex(); + timeline.getPeriod(currentPeriodIndex, period, /* setIds= */ true); + timeline.getWindow(player.getCurrentMediaItemIndex(), window); + + // We need the period of the content timeline because its period UIDs are the key used in the + // ad playback state map. The period UIDs of the public timeline are different (masking). + Timeline.Period contentPeriod = + checkNotNull(contentTimeline) + .getPeriod( + currentPeriodIndex - window.firstPeriodIndex, + new Timeline.Period(), + /* setIds= */ true); + AdPlaybackState adPlaybackState = checkNotNull(adPlaybackStates.get(contentPeriod.uid)); + + long streamPositionMs = + usToMs(ServerSideAdInsertionUtil.getStreamPositionUs(player, adPlaybackState)); + if (window.windowStartTimeMs != C.TIME_UNSET) { + // Add the time since epoch at start of the window for live streams. + streamPositionMs += window.windowStartTimeMs + period.getPositionInWindowMs(); + } else if (currentPeriodIndex > window.firstPeriodIndex) { + // Add the end position of the previous period in the underlying stream. + checkNotNull(contentTimeline) + .getPeriod( + currentPeriodIndex - window.firstPeriodIndex - 1, + contentPeriod, + /* setIds= */ true); + streamPositionMs += usToMs(contentPeriod.positionInWindowUs + contentPeriod.durationUs); + } + return new VideoProgressUpdate( + streamPositionMs, + checkNotNull(contentTimeline).getWindow(/* windowIndex= */ 0, window).getDurationMs()); + } + + // Implement VideoStreamPlayer. + + @Override + public void loadUrl(String url, List> subtitles) { + if (streamLoadListener != null) { + // SDK provided manifest url, notify the listener. + streamLoadListener.onLoadStream(url, subtitles); + } + } + + @Override + public void addCallback(VideoStreamPlayer.VideoStreamPlayerCallback callback) { + callbacks.add(callback); + } + + @Override + public void removeCallback(VideoStreamPlayer.VideoStreamPlayerCallback callback) { + callbacks.remove(callback); + } + + @Override + public void onAdBreakStarted() { + // Do nothing. + } + + @Override + public void onAdBreakEnded() { + // Do nothing. + } + + @Override + public void onAdPeriodStarted() { + // Do nothing. + } + + @Override + public void onAdPeriodEnded() { + // Do nothing. + } + + @Override + public void pause() { + // Do nothing. + } + + @Override + public void resume() { + // Do nothing. + } + + @Override + public void seek(long timeMs) { + // Do nothing. + } + + // Internal methods. + + private void triggerUserTextReceived(String userText) { + for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) { + callback.onUserTextReceived(userText); + } + } + } + + private static boolean isCurrentAdPlaying( + Player player, MediaItem mediaItem, @Nullable Object adsId) { + if (player.getPlaybackState() == Player.STATE_IDLE) { + return false; + } + Timeline.Period period = new Timeline.Period(); + player.getCurrentTimeline().getPeriod(player.getCurrentPeriodIndex(), period); + return (period.isPlaceholder && mediaItem.equals(player.getCurrentMediaItem())) + || (adsId != null && adsId.equals(period.getAdsId())); + } + + private static StreamDisplayContainer createStreamDisplayContainer( + ImaSdkFactory imaSdkFactory, + ImaUtil.ServerSideAdInsertionConfiguration config, + StreamPlayer streamPlayer) { + StreamDisplayContainer container = + ImaSdkFactory.createStreamDisplayContainer( + checkNotNull(config.adViewProvider.getAdViewGroup()), streamPlayer); + container.setCompanionSlots(config.companionAdSlots); + registerFriendlyObstructions(imaSdkFactory, container, config.adViewProvider); + return container; + } + + private static void registerFriendlyObstructions( + ImaSdkFactory imaSdkFactory, + StreamDisplayContainer container, + AdViewProvider adViewProvider) { + for (int i = 0; i < adViewProvider.getAdOverlayInfos().size(); i++) { + AdOverlayInfo overlayInfo = adViewProvider.getAdOverlayInfos().get(i); + container.registerFriendlyObstruction( + imaSdkFactory.createFriendlyObstruction( + overlayInfo.view, + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), + overlayInfo.reasonDetail != null ? overlayInfo.reasonDetail : "Unknown reason")); + } + } + + private static void assertSingleInstanceInPlaylist(Player player) { + int counter = 0; + for (int i = 0; i < player.getMediaItemCount(); i++) { + MediaItem mediaItem = player.getMediaItemAt(i); + if (mediaItem.localConfiguration != null + && C.SSAI_SCHEME.equals(mediaItem.localConfiguration.uri.getScheme()) + && ImaServerSideAdInsertionUriBuilder.IMA_AUTHORITY.equals( + mediaItem.localConfiguration.uri.getAuthority())) { + if (++counter > 1) { + throw new IllegalStateException( + "Multiple IMA server side ad insertion sources not supported."); + } + } + } + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaServerSideAdInsertionUriBuilder.java b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaServerSideAdInsertionUriBuilder.java new file mode 100644 index 0000000000..d7e6a26c5f --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaServerSideAdInsertionUriBuilder.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.brentvatne.exoplayer.ext.ima; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.StreamRequest; +import com.google.ads.interactivemedia.v3.api.StreamRequest.StreamFormat; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.ContentType; +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; + +/** + * Builder for URI for IMA DAI streams. The resulting URI can be used to build a {@link + * com.google.android.exoplayer2.MediaItem#fromUri(Uri) media item} that can be played by the {@link + * ImaServerSideAdInsertionMediaSource}. + */ +public final class ImaServerSideAdInsertionUriBuilder { + + /** The default timeout for loading the video URI, in milliseconds. */ + public static final int DEFAULT_LOAD_VIDEO_TIMEOUT_MS = 10_000; + + /* package */ static final String IMA_AUTHORITY = "dai.google.com"; + private static final String ADS_ID = "adsId"; + private static final String ASSET_KEY = "assetKey"; + private static final String API_KEY = "apiKey"; + private static final String CONTENT_SOURCE_ID = "contentSourceId"; + private static final String VIDEO_ID = "videoId"; + private static final String AD_TAG_PARAMETERS = "adTagParameters"; + private static final String MANIFEST_SUFFIX = "manifestSuffix"; + private static final String CONTENT_URL = "contentUrl"; + private static final String AUTH_TOKEN = "authToken"; + private static final String STREAM_ACTIVITY_MONITOR_ID = "streamActivityMonitorId"; + private static final String FORMAT = "format"; + private static final String LOAD_VIDEO_TIMEOUT_MS = "loadVideoTimeoutMs"; + + @Nullable private String adsId; + @Nullable private String assetKey; + @Nullable private String apiKey; + @Nullable private String contentSourceId; + @Nullable private String videoId; + @Nullable private String manifestSuffix; + @Nullable private String contentUrl; + @Nullable private String authToken; + @Nullable private String streamActivityMonitorId; + private ImmutableMap adTagParameters; + public @ContentType int format; + private int loadVideoTimeoutMs; + + /** Creates a new instance. */ + public ImaServerSideAdInsertionUriBuilder() { + adTagParameters = ImmutableMap.of(); + loadVideoTimeoutMs = DEFAULT_LOAD_VIDEO_TIMEOUT_MS; + format = C.CONTENT_TYPE_OTHER; + } + + /** + * An opaque identifier for associated ad playback state, or {@code null} if the {@link + * #setAssetKey(String) asset key} (for live) or {@link #setVideoId(String) video id} (for VOD) + * should be used as the ads identifier. + * + * @param adsId The ads identifier. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setAdsId(String adsId) { + this.adsId = adsId; + return this; + } + + /** + * The stream request asset key used for live streams. + * + * @param assetKey Live stream asset key. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setAssetKey(@Nullable String assetKey) { + this.assetKey = assetKey; + return this; + } + + /** + * Sets the stream request authorization token. Used in place of {@link #setApiKey(String) the API + * key} for stricter content authorization. The publisher can control individual content streams + * authorizations based on this token. + * + * @param authToken Live stream authorization token. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setAuthToken(@Nullable String authToken) { + this.authToken = authToken; + return this; + } + + /** + * The stream request content source ID used for on-demand streams. + * + * @param contentSourceId VOD stream content source id. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setContentSourceId(@Nullable String contentSourceId) { + this.contentSourceId = contentSourceId; + return this; + } + + /** + * The stream request video ID used for on-demand streams. + * + * @param videoId VOD stream video id. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setVideoId(@Nullable String videoId) { + this.videoId = videoId; + return this; + } + + /** + * Sets the format of the stream request. + * + * @param format {@link C#TYPE_DASH} or {@link C#TYPE_HLS}. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setFormat(@ContentType int format) { + checkArgument(format == C.CONTENT_TYPE_DASH || format == C.CONTENT_TYPE_HLS); + this.format = format; + return this; + } + + /** + * The stream request API key. This is used for content authentication. The API key is provided to + * the publisher to unlock their content. It's a security measure used to verify the applications + * that are attempting to access the content. + * + * @param apiKey Stream api key. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setApiKey(@Nullable String apiKey) { + this.apiKey = apiKey; + return this; + } + + /** + * Sets the ID to be used to debug the stream with the stream activity monitor. This is used to + * provide a convenient way to allow publishers to find a stream log in the stream activity + * monitor tool. + * + * @param streamActivityMonitorId ID for debugging the stream with the stream activity monitor. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setStreamActivityMonitorId( + @Nullable String streamActivityMonitorId) { + this.streamActivityMonitorId = streamActivityMonitorId; + return this; + } + + /** + * Sets the overridable ad tag parameters on the stream request. Supply targeting parameters to your + * stream provides more information. + * + *

You can use the dai-ot and dai-ov parameters for stream variant preference. See Override Stream Variant Parameters + * for more information. + * + * @param adTagParameters A map of extra parameters to pass to the ad server. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setAdTagParameters( + Map adTagParameters) { + this.adTagParameters = ImmutableMap.copyOf(adTagParameters); + return this; + } + + /** + * Sets the optional stream manifest's suffix, which will be appended to the stream manifest's + * URL. The provided string must be URL-encoded and must not include a leading question mark. + * + * @param manifestSuffix Stream manifest's suffix. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setManifestSuffix(@Nullable String manifestSuffix) { + this.manifestSuffix = manifestSuffix; + return this; + } + + /** + * Specifies the deep link to the content's screen. If provided, this parameter is passed to the + * OM SDK. See Android + * documentation for more information. + * + * @param contentUrl Deep link to the content's screen. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setContentUrl(@Nullable String contentUrl) { + this.contentUrl = contentUrl; + return this; + } + + /** + * Sets the duration after which resolving the video URI should time out, in milliseconds. + * + *

The default is {@link #DEFAULT_LOAD_VIDEO_TIMEOUT_MS} milliseconds. + * + * @param loadVideoTimeoutMs The timeout after which to give up resolving the video URI. + * @return This instance, for convenience. + */ + public ImaServerSideAdInsertionUriBuilder setLoadVideoTimeoutMs(int loadVideoTimeoutMs) { + this.loadVideoTimeoutMs = loadVideoTimeoutMs; + return this; + } + + /** + * Builds a URI with the builder's current values. + * + * @return The build {@link Uri}. + * @throws IllegalStateException If the builder has missing or invalid inputs. + */ + public Uri build() { + checkState( + (TextUtils.isEmpty(assetKey) + && !TextUtils.isEmpty(contentSourceId) + && !TextUtils.isEmpty(videoId)) + || (!TextUtils.isEmpty(assetKey) + && TextUtils.isEmpty(contentSourceId) + && TextUtils.isEmpty(videoId))); + checkState(format != C.CONTENT_TYPE_OTHER); + @Nullable String adsId = this.adsId; + if (adsId == null) { + adsId = assetKey != null ? assetKey : checkNotNull(videoId); + } + Uri.Builder dataUriBuilder = new Uri.Builder(); + dataUriBuilder.scheme(C.SSAI_SCHEME); + dataUriBuilder.authority(IMA_AUTHORITY); + dataUriBuilder.appendQueryParameter(ADS_ID, adsId); + if (loadVideoTimeoutMs != DEFAULT_LOAD_VIDEO_TIMEOUT_MS) { + dataUriBuilder.appendQueryParameter( + LOAD_VIDEO_TIMEOUT_MS, String.valueOf(loadVideoTimeoutMs)); + } + if (assetKey != null) { + dataUriBuilder.appendQueryParameter(ASSET_KEY, assetKey); + } + if (apiKey != null) { + dataUriBuilder.appendQueryParameter(API_KEY, apiKey); + } + if (contentSourceId != null) { + dataUriBuilder.appendQueryParameter(CONTENT_SOURCE_ID, contentSourceId); + } + if (videoId != null) { + dataUriBuilder.appendQueryParameter(VIDEO_ID, videoId); + } + if (manifestSuffix != null) { + dataUriBuilder.appendQueryParameter(MANIFEST_SUFFIX, manifestSuffix); + } + if (contentUrl != null) { + dataUriBuilder.appendQueryParameter(CONTENT_URL, contentUrl); + } + if (authToken != null) { + dataUriBuilder.appendQueryParameter(AUTH_TOKEN, authToken); + } + if (streamActivityMonitorId != null) { + dataUriBuilder.appendQueryParameter(STREAM_ACTIVITY_MONITOR_ID, streamActivityMonitorId); + } + if (!adTagParameters.isEmpty()) { + Uri.Builder adTagParametersUriBuilder = new Uri.Builder(); + for (Map.Entry entry : adTagParameters.entrySet()) { + adTagParametersUriBuilder.appendQueryParameter(entry.getKey(), entry.getValue()); + } + dataUriBuilder.appendQueryParameter( + AD_TAG_PARAMETERS, adTagParametersUriBuilder.build().toString()); + } + dataUriBuilder.appendQueryParameter(FORMAT, String.valueOf(format)); + return dataUriBuilder.build(); + } + + /** Returns whether the provided request is for a live stream or false if it is a VOD stream. */ + /* package */ static boolean isLiveStream(Uri uri) { + return !TextUtils.isEmpty(uri.getQueryParameter(ASSET_KEY)); + } + + /** Returns the opaque adsId for this stream. */ + /* package */ static String getAdsId(Uri uri) { + return checkNotNull(uri.getQueryParameter(ADS_ID)); + } + + /** Returns the video load timeout in milliseconds. */ + /* package */ static int getLoadVideoTimeoutMs(Uri uri) { + @Nullable String adsLoaderTimeoutUs = uri.getQueryParameter(LOAD_VIDEO_TIMEOUT_MS); + return TextUtils.isEmpty(adsLoaderTimeoutUs) + ? DEFAULT_LOAD_VIDEO_TIMEOUT_MS + : Integer.parseInt(adsLoaderTimeoutUs); + } + + /** Returns the corresponding {@link StreamRequest}. */ + @SuppressWarnings("nullness") // Required for making nullness test pass for library_with_ima_sdk. + /* package */ static StreamRequest createStreamRequest(Uri uri) { + if (!C.SSAI_SCHEME.equals(uri.getScheme()) || !IMA_AUTHORITY.equals(uri.getAuthority())) { + throw new IllegalArgumentException("Invalid URI scheme or authority."); + } + StreamRequest streamRequest; + // Required params. + @Nullable String assetKey = uri.getQueryParameter(ASSET_KEY); + @Nullable String apiKey = uri.getQueryParameter(API_KEY); + @Nullable String contentSourceId = uri.getQueryParameter(CONTENT_SOURCE_ID); + @Nullable String videoId = uri.getQueryParameter(VIDEO_ID); + if (!TextUtils.isEmpty(assetKey)) { + streamRequest = ImaSdkFactory.getInstance().createLiveStreamRequest(assetKey, apiKey); + } else { + streamRequest = + ImaSdkFactory.getInstance() + .createVodStreamRequest(checkNotNull(contentSourceId), checkNotNull(videoId), apiKey); + } + int format = Integer.parseInt(uri.getQueryParameter(FORMAT)); + if (format == C.CONTENT_TYPE_DASH) { + streamRequest.setFormat(StreamFormat.DASH); + } else if (format == C.CONTENT_TYPE_HLS) { + streamRequest.setFormat(StreamFormat.HLS); + } else { + throw new IllegalArgumentException("Unsupported stream format:" + format); + } + // Optional params. + @Nullable String adTagParametersValue = uri.getQueryParameter(AD_TAG_PARAMETERS); + if (!TextUtils.isEmpty(adTagParametersValue)) { + Map adTagParameters = new HashMap<>(); + Uri adTagParametersUri = Uri.parse(adTagParametersValue); + for (String paramName : adTagParametersUri.getQueryParameterNames()) { + String singleAdTagParameterValue = adTagParametersUri.getQueryParameter(paramName); + if (!TextUtils.isEmpty(singleAdTagParameterValue)) { + adTagParameters.put(paramName, singleAdTagParameterValue); + } + } + streamRequest.setAdTagParameters(adTagParameters); + } + @Nullable String manifestSuffix = uri.getQueryParameter(MANIFEST_SUFFIX); + if (manifestSuffix != null) { + streamRequest.setManifestSuffix(manifestSuffix); + } + @Nullable String contentUrl = uri.getQueryParameter(CONTENT_URL); + if (contentUrl != null) { + streamRequest.setContentUrl(contentUrl); + } + @Nullable String authToken = uri.getQueryParameter(AUTH_TOKEN); + if (authToken != null) { + streamRequest.setAuthToken(authToken); + } + @Nullable String streamActivityMonitorId = uri.getQueryParameter(STREAM_ACTIVITY_MONITOR_ID); + if (streamActivityMonitorId != null) { + streamRequest.setStreamActivityMonitorId(streamActivityMonitorId); + } + checkState( + streamRequest.getFormat() != StreamFormat.DASH + || TextUtils.isEmpty(streamRequest.getAssetKey()), + "DASH live streams are not supported yet."); + return streamRequest; + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaUtil.java b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaUtil.java new file mode 100644 index 0000000000..177e806d19 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/ImaUtil.java @@ -0,0 +1,543 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.brentvatne.exoplayer.ext.ima; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.sum; +import static java.lang.Math.max; + +import android.content.Context; +import android.os.Looper; +import android.util.Pair; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdError; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.AdsLoader; +import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; +import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; +import com.google.ads.interactivemedia.v3.api.FriendlyObstruction; +import com.google.ads.interactivemedia.v3.api.FriendlyObstructionPurpose; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.UiElement; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; +import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.ui.AdOverlayInfo; +import com.google.android.exoplayer2.ui.AdViewProvider; +import com.google.android.exoplayer2.upstream.DataSchemeDataSource; +import com.google.android.exoplayer2.upstream.DataSourceUtil; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.math.DoubleMath; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Utilities for working with IMA SDK and IMA extension data types. */ +/* package */ final class ImaUtil { + + /** Factory for objects provided by the IMA SDK. */ + public interface ImaFactory { + /** Creates {@link ImaSdkSettings} for configuring the IMA SDK. */ + ImaSdkSettings createImaSdkSettings(); + /** + * Creates {@link AdsRenderingSettings} for giving the {@link AdsManager} parameters that + * control rendering of ads. + */ + AdsRenderingSettings createAdsRenderingSettings(); + /** + * Creates an {@link AdDisplayContainer} to hold the player for video ads, a container for + * non-linear ads, and slots for companion ads. + */ + AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player); + /** Creates an {@link AdDisplayContainer} to hold the player for audio ads. */ + AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player); + /** + * Creates a {@link FriendlyObstruction} to describe an obstruction considered "friendly" for + * viewability measurement purposes. + */ + FriendlyObstruction createFriendlyObstruction( + View view, + FriendlyObstructionPurpose friendlyObstructionPurpose, + @Nullable String reasonDetail); + /** Creates an {@link AdsRequest} to contain the data used to request ads. */ + AdsRequest createAdsRequest(); + /** Creates an {@link AdsLoader} for requesting ads using the specified settings. */ + AdsLoader createAdsLoader( + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); + } + + /** Stores configuration for ad loading and playback. */ + public static final class Configuration { + + public final long adPreloadTimeoutMs; + public final int vastLoadTimeoutMs; + public final int mediaLoadTimeoutMs; + public final boolean focusSkipButtonWhenAvailable; + public final boolean playAdBeforeStartPosition; + public final int mediaBitrate; + @Nullable public final Boolean enableContinuousPlayback; + @Nullable public final List adMediaMimeTypes; + @Nullable public final Set adUiElements; + @Nullable public final Collection companionAdSlots; + @Nullable public final AdErrorEvent.AdErrorListener applicationAdErrorListener; + @Nullable public final AdEvent.AdEventListener applicationAdEventListener; + @Nullable public final VideoAdPlayer.VideoAdPlayerCallback applicationVideoAdPlayerCallback; + @Nullable public final ImaSdkSettings imaSdkSettings; + public final boolean debugModeEnabled; + + public Configuration( + long adPreloadTimeoutMs, + int vastLoadTimeoutMs, + int mediaLoadTimeoutMs, + boolean focusSkipButtonWhenAvailable, + boolean playAdBeforeStartPosition, + int mediaBitrate, + @Nullable Boolean enableContinuousPlayback, + @Nullable List adMediaMimeTypes, + @Nullable Set adUiElements, + @Nullable Collection companionAdSlots, + @Nullable AdErrorEvent.AdErrorListener applicationAdErrorListener, + @Nullable AdEvent.AdEventListener applicationAdEventListener, + @Nullable VideoAdPlayer.VideoAdPlayerCallback applicationVideoAdPlayerCallback, + @Nullable ImaSdkSettings imaSdkSettings, + boolean debugModeEnabled) { + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + this.vastLoadTimeoutMs = vastLoadTimeoutMs; + this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; + this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = playAdBeforeStartPosition; + this.mediaBitrate = mediaBitrate; + this.enableContinuousPlayback = enableContinuousPlayback; + this.adMediaMimeTypes = adMediaMimeTypes; + this.adUiElements = adUiElements; + this.companionAdSlots = companionAdSlots; + this.applicationAdErrorListener = applicationAdErrorListener; + this.applicationAdEventListener = applicationAdEventListener; + this.applicationVideoAdPlayerCallback = applicationVideoAdPlayerCallback; + this.imaSdkSettings = imaSdkSettings; + this.debugModeEnabled = debugModeEnabled; + } + } + + /** Stores configuration for playing server side ad insertion content. */ + public static final class ServerSideAdInsertionConfiguration { + + public final AdViewProvider adViewProvider; + public final ImaSdkSettings imaSdkSettings; + @Nullable public final AdEvent.AdEventListener applicationAdEventListener; + @Nullable public final AdErrorEvent.AdErrorListener applicationAdErrorListener; + public final ImmutableList companionAdSlots; + public final boolean debugModeEnabled; + + public ServerSideAdInsertionConfiguration( + AdViewProvider adViewProvider, + ImaSdkSettings imaSdkSettings, + @Nullable AdEvent.AdEventListener applicationAdEventListener, + @Nullable AdErrorEvent.AdErrorListener applicationAdErrorListener, + List companionAdSlots, + boolean debugModeEnabled) { + this.imaSdkSettings = imaSdkSettings; + this.adViewProvider = adViewProvider; + this.applicationAdEventListener = applicationAdEventListener; + this.applicationAdErrorListener = applicationAdErrorListener; + this.companionAdSlots = ImmutableList.copyOf(companionAdSlots); + this.debugModeEnabled = debugModeEnabled; + } + } + + public static final int TIMEOUT_UNSET = -1; + public static final int BITRATE_UNSET = -1; + + /** + * Returns the IMA {@link FriendlyObstructionPurpose} corresponding to the given {@link + * AdOverlayInfo#purpose}. + */ + public static FriendlyObstructionPurpose getFriendlyObstructionPurpose( + @AdOverlayInfo.Purpose int purpose) { + switch (purpose) { + case AdOverlayInfo.PURPOSE_CONTROLS: + return FriendlyObstructionPurpose.VIDEO_CONTROLS; + case AdOverlayInfo.PURPOSE_CLOSE_AD: + return FriendlyObstructionPurpose.CLOSE_AD; + case AdOverlayInfo.PURPOSE_NOT_VISIBLE: + return FriendlyObstructionPurpose.NOT_VISIBLE; + case AdOverlayInfo.PURPOSE_OTHER: + default: + return FriendlyObstructionPurpose.OTHER; + } + } + + /** + * Returns the microsecond ad group timestamps corresponding to the specified cue points. + * + * @param cuePoints The cue points of the ads in seconds, provided by the IMA SDK. + * @return The corresponding microsecond ad group timestamps. + */ + public static long[] getAdGroupTimesUsForCuePoints(List cuePoints) { + if (cuePoints.isEmpty()) { + return new long[] {0L}; + } + + int count = cuePoints.size(); + long[] adGroupTimesUs = new long[count]; + int adGroupIndex = 0; + for (int i = 0; i < count; i++) { + double cuePoint = cuePoints.get(i); + if (cuePoint == -1.0) { + adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; + } else { + adGroupTimesUs[adGroupIndex++] = Math.round(C.MICROS_PER_SECOND * cuePoint); + } + } + // Cue points may be out of order, so sort them. + Arrays.sort(adGroupTimesUs, 0, adGroupIndex); + return adGroupTimesUs; + } + + /** Returns an {@link AdsRequest} based on the specified ad tag {@link DataSpec}. */ + public static AdsRequest getAdsRequestForAdTagDataSpec( + ImaFactory imaFactory, DataSpec adTagDataSpec) throws IOException { + AdsRequest request = imaFactory.createAdsRequest(); + if (DataSchemeDataSource.SCHEME_DATA.equals(adTagDataSpec.uri.getScheme())) { + DataSchemeDataSource dataSchemeDataSource = new DataSchemeDataSource(); + try { + dataSchemeDataSource.open(adTagDataSpec); + request.setAdsResponse(Util.fromUtf8Bytes(DataSourceUtil.readToEnd(dataSchemeDataSource))); + } finally { + dataSchemeDataSource.close(); + } + } else { + request.setAdTagUrl(adTagDataSpec.uri.toString()); + } + return request; + } + + /** Returns whether the ad error indicates that an entire ad group failed to load. */ + public static boolean isAdGroupLoadError(AdError adError) { + // TODO: Find out what other errors need to be handled (if any), and whether each one relates to + // a single ad, ad group or the whole timeline. + return adError.getErrorCode() == AdError.AdErrorCode.VAST_LINEAR_ASSET_MISMATCH + || adError.getErrorCode() == AdError.AdErrorCode.UNKNOWN_ERROR; + } + + /** Returns the looper on which all IMA SDK interaction must occur. */ + public static Looper getImaLooper() { + // IMA SDK callbacks occur on the main thread. This method can be used to check that the player + // is using the same looper, to ensure all interaction with this class is on the main thread. + return Looper.getMainLooper(); + } + + /** Returns a human-readable representation of a video progress update. */ + public static String getStringForVideoProgressUpdate(VideoProgressUpdate videoProgressUpdate) { + if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { + return "not ready"; + } else { + return Util.formatInvariant( + "%d ms of %d ms", + videoProgressUpdate.getCurrentTimeMs(), videoProgressUpdate.getDurationMs()); + } + } + + /** + * Expands a placeholder ad group with a single ad to the requested number of ads and sets the + * duration of the inserted ad. + * + *

The remaining ad group duration is propagated to the ad following the inserted ad. If the + * inserted ad is the last ad, the remaining ad group duration is wrapped around to the first ad + * in the group. + * + * @param adGroupIndex The ad group index of the ad group to expand. + * @param adIndexInAdGroup The ad index to set the duration. + * @param adDurationUs The duration of the ad. + * @param adGroupDurationUs The duration of the whole ad group. + * @param adsInAdGroupCount The number of ads of the ad group. + * @param adPlaybackState The ad playback state to modify. + * @return The updated ad playback state. + */ + @CheckResult + public static AdPlaybackState expandAdGroupPlaceholder( + int adGroupIndex, + long adGroupDurationUs, + int adIndexInAdGroup, + long adDurationUs, + int adsInAdGroupCount, + AdPlaybackState adPlaybackState) { + checkArgument(adIndexInAdGroup < adsInAdGroupCount); + long[] adDurationsUs = + updateAdDurationAndPropagate( + new long[adsInAdGroupCount], adIndexInAdGroup, adDurationUs, adGroupDurationUs); + return adPlaybackState + .withAdCount(adGroupIndex, adDurationsUs.length) + .withAdDurationsUs(adGroupIndex, adDurationsUs); + } + + /** + * Updates the duration of an ad in and ad group. + * + *

The difference of the previous duration and the updated duration is propagated to the ad + * following the updated ad. If the updated ad is the last ad, the remaining duration is wrapped + * around to the first ad in the group. + * + *

The remaining ad duration is only propagated if the destination ad has a duration of 0. + * + * @param adGroupIndex The ad group index of the ad group to expand. + * @param adIndexInAdGroup The ad index to set the duration. + * @param adDurationUs The duration of the ad. + * @param adPlaybackState The ad playback state to modify. + * @return The updated ad playback state. + */ + @CheckResult + public static AdPlaybackState updateAdDurationInAdGroup( + int adGroupIndex, int adIndexInAdGroup, long adDurationUs, AdPlaybackState adPlaybackState) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + checkArgument(adIndexInAdGroup < adGroup.durationsUs.length); + long[] adDurationsUs = + updateAdDurationAndPropagate( + Arrays.copyOf(adGroup.durationsUs, adGroup.durationsUs.length), + adIndexInAdGroup, + adDurationUs, + adGroup.durationsUs[adIndexInAdGroup]); + return adPlaybackState.withAdDurationsUs(adGroupIndex, adDurationsUs); + } + + /** + * Updates the duration of the given ad in the array and propagates the difference to the total + * duration to the next ad. If the updated ad is the last ad, the remaining duration is wrapped + * around to the first ad in the group. + * + *

The remaining ad duration is only propagated if the destination ad has a duration of 0. + * + * @param adDurationsUs The array to edit. + * @param adIndex The index of the ad in the durations array. + * @param adDurationUs The new ad duration. + * @param totalDurationUs The total duration the difference of which to propagate to the next ad. + * @return The updated input array, for convenience. + */ + /* package */ static long[] updateAdDurationAndPropagate( + long[] adDurationsUs, int adIndex, long adDurationUs, long totalDurationUs) { + adDurationsUs[adIndex] = adDurationUs; + int nextAdIndex = (adIndex + 1) % adDurationsUs.length; + if (adDurationsUs[nextAdIndex] == 0) { + // Propagate the remaining duration to the next ad. + adDurationsUs[nextAdIndex] = max(0, totalDurationUs - adDurationUs); + } + return adDurationsUs; + } + + /** + * Splits an {@link AdPlaybackState} into a separate {@link AdPlaybackState} for each period of a + * content timeline. + * + *

If a period is enclosed by an ad group, the period is considered an ad period. Splitting + * results in a separate {@link AdPlaybackState ad playback state} for each period that has either + * no ads or a single ad. In the latter case, the duration of the single ad is set to the duration + * of the period consuming the entire duration of the period. Accordingly an ad period does not + * contribute to the duration of the containing window. + * + * @param adPlaybackState The ad playback state to be split. + * @param contentTimeline The content timeline for each period of which to create an {@link + * AdPlaybackState}. + * @return A map of ad playback states for each period UID in the content timeline. + */ + public static ImmutableMap splitAdPlaybackStateForPeriods( + AdPlaybackState adPlaybackState, Timeline contentTimeline) { + Timeline.Period period = new Timeline.Period(); + if (contentTimeline.getPeriodCount() == 1) { + // A single period gets the entire ad playback state that may contain multiple ad groups. + return ImmutableMap.of( + checkNotNull( + contentTimeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).uid), + adPlaybackState); + } + + int periodIndex = 0; + long totalElapsedContentDurationUs = 0; + Object adsId = checkNotNull(adPlaybackState.adsId); + AdPlaybackState contentOnlyAdPlaybackState = new AdPlaybackState(adsId); + Map adPlaybackStates = new HashMap<>(); + for (int i = adPlaybackState.removedAdGroupCount; i < adPlaybackState.adGroupCount; i++) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); + if (adGroup.timeUs == C.TIME_END_OF_SOURCE) { + checkState(i == adPlaybackState.adGroupCount - 1); + // The last ad group is a placeholder for a potential post roll. We can just stop here. + break; + } + // The ad group start timeUs is in content position. We need to add the ad + // duration before the ad group to translate the start time to the position in the period. + long adGroupDurationUs = sum(adGroup.durationsUs); + long elapsedAdGroupAdDurationUs = 0; + for (int j = periodIndex; j < contentTimeline.getPeriodCount(); j++) { + contentTimeline.getPeriod(j, period, /* setIds= */ true); + if (totalElapsedContentDurationUs < adGroup.timeUs) { + // Period starts before the ad group, so it is a content period. + adPlaybackStates.put(checkNotNull(period.uid), contentOnlyAdPlaybackState); + totalElapsedContentDurationUs += period.durationUs; + } else { + long periodStartUs = totalElapsedContentDurationUs + elapsedAdGroupAdDurationUs; + if (periodStartUs + period.durationUs <= adGroup.timeUs + adGroupDurationUs) { + // The period ends before the end of the ad group, so it is an ad period (Note: A VOD ad + // reported by the IMA SDK spans multiple periods before the LOADED event arrives). + adPlaybackStates.put( + checkNotNull(period.uid), + splitAdGroupForPeriod(adsId, adGroup, periodStartUs, period.durationUs)); + elapsedAdGroupAdDurationUs += period.durationUs; + } else { + // Period is after the current ad group. Continue with next ad group. + break; + } + } + // Increment the period index to the next unclassified period. + periodIndex++; + } + } + // The remaining periods end after the last ad group, so these are content periods. + for (int i = periodIndex; i < contentTimeline.getPeriodCount(); i++) { + contentTimeline.getPeriod(i, period, /* setIds= */ true); + adPlaybackStates.put(checkNotNull(period.uid), contentOnlyAdPlaybackState); + } + return ImmutableMap.copyOf(adPlaybackStates); + } + + private static AdPlaybackState splitAdGroupForPeriod( + Object adsId, AdPlaybackState.AdGroup adGroup, long periodStartUs, long periodDurationUs) { + AdPlaybackState adPlaybackState = + new AdPlaybackState(checkNotNull(adsId), /* adGroupTimesUs...= */ 0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, periodDurationUs) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, adGroup.contentResumeOffsetUs); + long periodEndUs = periodStartUs + periodDurationUs; + long adDurationsUs = 0; + for (int i = 0; i < adGroup.count; i++) { + adDurationsUs += adGroup.durationsUs[i]; + if (periodEndUs <= adGroup.timeUs + adDurationsUs + 10_000) { + // Map the state of the global ad state to the period specific ad state. + switch (adGroup.states[i]) { + case AdPlaybackState.AD_STATE_PLAYED: + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + break; + case AdPlaybackState.AD_STATE_SKIPPED: + adPlaybackState = + adPlaybackState.withSkippedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + break; + case AdPlaybackState.AD_STATE_ERROR: + adPlaybackState = + adPlaybackState.withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + break; + default: + // Do nothing. + break; + } + break; + } + } + return adPlaybackState; + } + + /** + * Returns the {@code adGroupIndex} and the {@code adIndexInAdGroup} for the given period index of + * an ad period. + * + * @param adPeriodIndex The period index of the ad period. + * @param adPlaybackState The ad playback state that holds the ad group and ad information. + * @param contentTimeline The timeline that contains the ad period. + * @return A pair with the ad group index (first) and the ad index in that ad group (second). + */ + public static Pair getAdGroupAndIndexInMultiPeriodWindow( + int adPeriodIndex, AdPlaybackState adPlaybackState, Timeline contentTimeline) { + Timeline.Period period = new Timeline.Period(); + int periodIndex = 0; + long totalElapsedContentDurationUs = 0; + for (int i = adPlaybackState.removedAdGroupCount; i < adPlaybackState.adGroupCount; i++) { + int adIndexInAdGroup = 0; + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); + long adGroupDurationUs = sum(adGroup.durationsUs); + long elapsedAdGroupAdDurationUs = 0; + for (int j = periodIndex; j < contentTimeline.getPeriodCount(); j++) { + contentTimeline.getPeriod(j, period, /* setIds= */ true); + if (totalElapsedContentDurationUs < adGroup.timeUs) { + // Period starts before the ad group, so it is a content period. + totalElapsedContentDurationUs += period.durationUs; + } else { + long periodStartUs = totalElapsedContentDurationUs + elapsedAdGroupAdDurationUs; + if (periodStartUs + period.durationUs <= adGroup.timeUs + adGroupDurationUs) { + // The period ends before the end of the ad group, so it is an ad period. + if (j == adPeriodIndex) { + return new Pair<>(/* adGroupIndex= */ i, adIndexInAdGroup); + } + elapsedAdGroupAdDurationUs += period.durationUs; + adIndexInAdGroup++; + } else { + // Period is after the current ad group. Continue with next ad group. + break; + } + } + // Increment the period index to the next unclassified period. + periodIndex++; + } + } + throw new IllegalStateException(); + } + + /** + * Converts a time in seconds to the corresponding time in microseconds. + * + *

Fractional values are rounded to the nearest microsecond using {@link RoundingMode#HALF_UP}. + * + * @param timeSec The time in seconds. + * @return The corresponding time in microseconds. + */ + public static long secToUsRounded(double timeSec) { + return DoubleMath.roundToLong( + BigDecimal.valueOf(timeSec).scaleByPowerOfTen(6).doubleValue(), RoundingMode.HALF_UP); + } + + /** + * Converts a time in seconds to the corresponding time in milliseconds. + * + *

Fractional values are rounded to the nearest millisecond using {@link RoundingMode#HALF_UP}. + * + * @param timeSec The time in seconds. + * @return The corresponding time in milliseconds. + */ + public static long secToMsRounded(double timeSec) { + return DoubleMath.roundToLong( + BigDecimal.valueOf(timeSec).scaleByPowerOfTen(3).doubleValue(), RoundingMode.HALF_UP); + } + + private ImaUtil() {} +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ext/ima/package-info.java b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/package-info.java new file mode 100644 index 0000000000..3247a93262 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ext/ima/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.brentvatne.exoplayer.ext.ima; + +import com.google.android.exoplayer2.util.NonNullApi;