diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bd3441606a4..1b8fb18a679 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -172,6 +172,8 @@ ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. +* IMA extension: Upgrade to IMA SDK version 3.18.1, and migrate to new + preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). ### 2.11.4 (2020-04-08) diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index 1bd0b8dbd46..f5d29efb978 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -39,7 +39,7 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.11.3' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.18.1' implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 75ed45852fc..77e0f0f7e85 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -19,6 +19,7 @@ import android.content.Context; import android.net.Uri; +import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.view.View; @@ -26,7 +27,6 @@ import androidx.annotation.IntDef; 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.AdError; import com.google.ads.interactivemedia.v3.api.AdError.AdErrorCode; @@ -45,6 +45,7 @@ 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.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; @@ -54,7 +55,6 @@ 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.AdPlaybackState.AdState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -71,6 +71,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -277,6 +278,14 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { 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. 100 ms is + * the interval recommended by the IMA documentation. + * + * @see com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback + */ + private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100; + /** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */ private static final long IMA_DURATION_UNSET = -1L; @@ -286,9 +295,6 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { */ private static final long END_OF_CONTENT_THRESHOLD_MS = 5000; - /** The maximum duration before an ad break that IMA may start preloading the next ad. */ - private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000; - private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -302,11 +308,12 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd()} and not {@link #pauseAd()}. + * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link + * #pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd()} while playing an ad. + * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; @@ -320,9 +327,12 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { @Nullable private final AdEventListener adEventListener; private final ImaFactory imaFactory; private final Timeline.Period period; + private final Handler handler; private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final Runnable updateAdProgressRunnable; + private final Map adInfoByAdMediaInfo; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; @@ -340,19 +350,18 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; private long contentDurationMs; - private int podIndexOffset; private AdPlaybackState adPlaybackState; // Fields tracking IMA's state. - /** The expected ad group index that IMA should load next. */ - private int expectedAdGroupIndex; - /** The index of the current ad group that IMA is loading. */ - private int adGroupIndex; /** 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 {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been * called since starting ad playback. @@ -363,20 +372,23 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { /** 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; /** - * Whether there's a pending ad preparation error which IMA needs to be notified of when it - * transitions from playing content to playing the ad. + * The ad info for a pending ad for which the media failed preparation, or {@code null} if no + * pending ads have failed to prepare. */ - private boolean shouldNotifyAdPrepareError; + @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd()}, 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. + * If a content period has finished but IMA has not yet called {@link #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; /** @@ -441,7 +453,7 @@ public ImaAdsLoader(Context context, Uri adTagUri, @Nullable ImaSdkSettings imaS /* imaFactory= */ new DefaultImaFactory()); } - @SuppressWarnings("nullness:argument.type.incompatible") + @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, @Nullable Uri adTagUri, @@ -473,6 +485,7 @@ private ImaAdsLoader( imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); + handler = Util.createHandler(getImaLooper(), /* callback= */ null); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); @@ -481,13 +494,14 @@ private ImaAdsLoader( context.getApplicationContext(), imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(/* adErrorListener= */ this); adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + updateAdProgressRunnable = this::updateAdProgress; + adInfoByAdMediaInfo = new HashMap<>(); supportedMimeTypes = Collections.emptyList(); lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; fakeContentProgressOffsetMs = C.TIME_UNSET; pendingContentPositionMs = C.TIME_UNSET; - adGroupIndex = C.INDEX_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; adPlaybackState = AdPlaybackState.NONE; @@ -562,9 +576,8 @@ public void requestAds(ViewGroup adViewGroup) { @Override public void setPlayer(@Nullable Player player) { - Assertions.checkState(Looper.getMainLooper() == Looper.myLooper()); - Assertions.checkState( - player == null || player.getApplicationLooper() == Looper.getMainLooper()); + Assertions.checkState(Looper.myLooper() == getImaLooper()); + Assertions.checkState(player == null || player.getApplicationLooper() == getImaLooper()); nextPlayer = player; wasSetPlayerCalled = true; } @@ -640,7 +653,7 @@ public void stop() { playingAd ? C.msToUs(player.getCurrentPosition()) : 0); } lastVolumePercentage = getVolume(); - lastAdProgress = getAdProgress(); + lastAdProgress = getAdVideoProgressUpdate(); lastContentProgress = getContentProgress(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); @@ -664,6 +677,8 @@ public void release() { adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; + imaAdMediaInfo = null; + imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; hasAdPlaybackState = false; @@ -768,32 +783,11 @@ public VideoProgressUpdate getContentProgress() { if (pendingContentPositionMs != C.TIME_UNSET) { sentPendingContentPositionMs = true; contentPositionMs = pendingContentPositionMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { contentPositionMs = getContentPeriodPositionMs(player, timeline, period); - // Update the expected ad group index for the current content position. The update is delayed - // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered - // just after an ad group isn't incorrectly attributed to the next ad group. - int nextAdGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (nextAdGroupIndex != expectedAdGroupIndex && nextAdGroupIndex != C.INDEX_UNSET) { - long nextAdGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[nextAdGroupIndex]); - if (nextAdGroupTimeMs == C.TIME_END_OF_SOURCE) { - nextAdGroupTimeMs = contentDurationMs; - } - if (nextAdGroupTimeMs - contentPositionMs < MAXIMUM_PRELOAD_DURATION_MS) { - expectedAdGroupIndex = nextAdGroupIndex; - } - } } else { return VideoProgressUpdate.VIDEO_TIME_NOT_READY; } @@ -805,15 +799,7 @@ public VideoProgressUpdate getContentProgress() { @Override public VideoProgressUpdate getAdProgress() { - 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; - } + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); } @Override @@ -839,30 +825,37 @@ public int getVolume() { } @Override - public void loadAd(String adUriString) { + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { try { if (DEBUG) { - Log.d(TAG, "loadAd in ad group " + adGroupIndex); + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); } if (adsManager == null) { // Drop events after release. return; } - if (adGroupIndex == C.INDEX_UNSET) { - adGroupIndex = expectedAdGroupIndex; - adsManager.start(); - Log.w( - TAG, - "Unexpected loadAd without LOADED event; assuming ad group index is actually " - + expectedAdGroupIndex); + int adGroupIndex = getAdGroupIndex(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; } - int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex); - if (adIndexInAdGroup == C.INDEX_UNSET) { - Log.w(TAG, "Unexpected loadAd in an ad group with no remaining unavailable ads"); - return; + 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= */ adGroupIndex, /* adIndexInAdGroup= */ i); + } } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); adPlaybackState = - adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString)); + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); updateAdPlaybackState(); } catch (Exception e) { maybeNotifyInternalError("loadAd", e); @@ -880,69 +873,62 @@ public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { } @Override - public void playAd() { + public void playAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "playAd"); + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { // Drop events after release. return; } - switch (imaAdState) { - case IMA_AD_STATE_PLAYING: - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028, b/63320878]. - Log.w(TAG, "Unexpected playAd without stopAd"); - break; - case 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; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(); - } - if (shouldNotifyAdPrepareError) { - shouldNotifyAdPrepareError = false; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); - } - } - break; - case IMA_AD_STATE_PAUSED: - imaAdState = IMA_AD_STATE_PLAYING; + + 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 = Assertions.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).onResume(); + adCallbacks.get(i).onError(adMediaInfo); } - break; - default: - throw new IllegalStateException(); + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected playAd while detached"); - } else if (!player.getPlayWhenReady()) { + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { Assertions.checkNotNull(adsManager).pause(); } } @Override - public void stopAd() { + public void stopAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "stopAd"); + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { // Drop event after release. return; } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected stopAd while detached"); - } - if (imaAdState == IMA_AD_STATE_NONE) { - Log.w(TAG, "Unexpected stopAd"); - return; - } + + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { stopAdInternal(); } catch (Exception e) { @@ -951,26 +937,21 @@ public void stopAd() { } @Override - public void pauseAd() { + public void pauseAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "pauseAd"); + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); } if (imaAdState == IMA_AD_STATE_NONE) { // This method is called after content is resumed. return; } + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); imaAdState = IMA_AD_STATE_PAUSED; for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(); + adCallbacks.get(i).onPause(adMediaInfo); } } - @Override - public void resumeAd() { - // This method is never called. See [Internal: b/18931719]. - maybeNotifyInternalError("resumeAd", new IllegalStateException("Unexpected call to resumeAd")); - } - // Player.EventListener implementation. @Override @@ -1028,8 +1009,9 @@ public void onPlayWhenReadyChanged( @Override public void onPlayerError(ExoPlaybackException error) { if (imaAdState != IMA_AD_STATE_NONE) { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(adMediaInfo); } } } @@ -1071,25 +1053,13 @@ private void initializeAdsManager(AdsManager adsManager) { adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); } - // IMA indexes any remaining midroll ad pods from 1. A preroll (if present) has index 0. - // Store an index offset as we want to index all ads (including skipped ones) from 0. - if (adGroupIndexForPosition == 0 && adGroupTimesUs[0] == 0) { - // We are playing a preroll. - podIndexOffset = 0; - } else if (adGroupIndexForPosition == C.INDEX_UNSET) { - // There's no ad to play which means there's no preroll. - podIndexOffset = -1; - } else { - // We are playing a midroll and any ads before it were skipped. - podIndexOffset = adGroupIndexForPosition - 1; - } - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { // Provide the player's initial position to trigger loading and playing the ad. pendingContentPositionMs = contentPositionMs; } adsManager.init(adsRenderingSettings); + adsManager.start(); updateAdPlaybackState(); if (DEBUG) { Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); @@ -1097,39 +1067,32 @@ private void initializeAdsManager(AdsManager adsManager) { } private void handleAdEvent(AdEvent adEvent) { - Ad ad = adEvent.getAd(); switch (adEvent.getType()) { - case LOADED: - // The ad position is not always accurate when using preloading. See [Internal: b/62613240]. - AdPodInfo adPodInfo = ad.getAdPodInfo(); - int podIndex = adPodInfo.getPodIndex(); - adGroupIndex = - podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset); - int adPosition = adPodInfo.getAdPosition(); - int adCount = adPodInfo.getTotalAds(); - Assertions.checkNotNull(adsManager).start(); + case AD_BREAK_FETCH_ERROR: + String adGroupTimeSecondsString = + Assertions.checkNotNull(adEvent.getAdData().get("adBreakTime")); if (DEBUG) { - Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex); + Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); } - int oldAdCount = adPlaybackState.adGroups[adGroupIndex].count; - if (adCount != oldAdCount) { - if (oldAdCount == C.LENGTH_UNSET) { - adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount); - updateAdPlaybackState(); - } else { - // IMA sometimes unexpectedly decreases the ad count in an ad group. - Log.w(TAG, "Unexpected ad count in LOADED, " + adCount + ", expected " + oldAdCount); - } + int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); + int adGroupIndex = + Arrays.binarySearch( + adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adGroupIndex]; } - if (adGroupIndex != expectedAdGroupIndex) { - Log.w( - TAG, - "Expected ad group index " - + expectedAdGroupIndex - + ", actual ad group index " - + adGroupIndex); - expectedAdGroupIndex = adGroupIndex; + for (int i = 0; i < adGroup.count; i++) { + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + if (DEBUG) { + Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); + } } + updateAdPlaybackState(); break; case CONTENT_PAUSE_REQUESTED: // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads @@ -1155,23 +1118,65 @@ private void handleAdEvent(AdEvent adEvent) { Map adData = adEvent.getAdData(); String message = "AdEvent: " + adData; Log.i(TAG, message); - if ("adLoadError".equals(adData.get("type"))) { - handleAdGroupLoadError(new IOException(message)); - } break; default: break; } } + 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(); + AdMediaInfo adMediaInfo = Assertions.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 void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { + if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { + AdMediaInfo adMediaInfo = Assertions.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) { checkForContentComplete(); } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + AdMediaInfo adMediaInfo = Assertions.checkNotNull(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 (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged"); @@ -1200,9 +1205,6 @@ private void handleTimelineOrPositionChanged() { if (newAdGroupIndex != C.INDEX_UNSET) { sentPendingContentPositionMs = false; pendingContentPositionMs = positionMs; - if (newAdGroupIndex != adGroupIndex) { - shouldNotifyAdPrepareError = false; - } } } } @@ -1215,8 +1217,13 @@ private void handleTimelineOrPositionChanged() { 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. - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + @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 (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); @@ -1234,15 +1241,8 @@ private void handleTimelineOrPositionChanged() { } private void resumeContentInternal() { - if (imaAdState != IMA_AD_STATE_NONE) { - imaAdState = IMA_AD_STATE_NONE; - if (DEBUG) { - Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd"); - } - } - if (adGroupIndex != C.INDEX_UNSET) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex); - adGroupIndex = C.INDEX_UNSET; + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); updateAdPlaybackState(); } } @@ -1257,23 +1257,40 @@ private void pauseContentInternal() { private void stopAdInternal() { imaAdState = IMA_AD_STATE_NONE; - int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); + stopUpdatingAdProgress(); // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + Assertions.checkNotNull(imaAdInfo); + int adGroupIndex = imaAdInfo.adGroupIndex; + int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); if (!playingAd) { - adGroupIndex = C.INDEX_UNSET; + imaAdMediaInfo = null; + imaAdInfo = null; } } private void handleAdGroupLoadError(Exception error) { + if (player == null) { + return; + } + + // TODO: Once IMA signals which ad group failed to load, clean up this code. + long playerPositionMs = player.getContentPosition(); int adGroupIndex = - this.adGroupIndex == C.INDEX_UNSET ? expectedAdGroupIndex : this.adGroupIndex; + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); if (adGroupIndex == C.INDEX_UNSET) { - // Drop the error, as we don't know which ad group it relates to. - return; + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + // The error doesn't seem to relate to any ad group so give up handling it. + return; + } } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = @@ -1313,19 +1330,20 @@ private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Except if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { fakeContentProgressOffsetMs = contentDurationMs; } - shouldNotifyAdPrepareError = true; + pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); } else { + AdMediaInfo adMediaInfo = Assertions.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(); + adCallbacks.get(i).onEnded(adMediaInfo); } } playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(Assertions.checkNotNull(adMediaInfo)); } } adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); @@ -1343,11 +1361,6 @@ private void checkForContentComplete() { Log.d(TAG, "adsLoader.contentComplete"); } sentContentComplete = true; - // After sending content complete IMA will not poll the content position, so set the expected - // ad group index. - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentDurationMs), C.msToUs(contentDurationMs)); } } @@ -1358,21 +1371,6 @@ private void updateAdPlaybackState() { } } - /** - * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all - * ads in the ad group have loaded. - */ - private int getAdIndexInAdGroupToLoad(int adGroupIndex) { - @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states; - int adIndexInAdGroup = 0; - // IMA loads ads in order. - while (adIndexInAdGroup < states.length - && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) { - adIndexInAdGroup++; - } - return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup; - } - private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); @@ -1406,6 +1404,22 @@ private static long getContentPeriodPositionMs( - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); } + private int getAdGroupIndex(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. + long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) { + return adGroupIndex; + } + } + throw new IllegalStateException("Failed to find cue point"); + } + private static long[] getAdGroupTimesUs(List cuePoints) { if (cuePoints.isEmpty()) { // If no cue points are specified, there is a preroll ad. @@ -1435,6 +1449,12 @@ private static boolean isAdGroupLoadError(AdError adError) { || adError.getErrorCode() == AdErrorCode.UNKNOWN_ERROR; } + private 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(); + } + private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { int count = adGroupTimesUs.length; if (count == 1) { @@ -1463,6 +1483,49 @@ com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; + } + + // 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 + ')'; + } + } + /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */ private static final class DefaultImaFactory implements ImaFactory { @Override diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 627c583e3ac..6405583bf1b 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -41,6 +41,7 @@ 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.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Player; @@ -85,6 +86,7 @@ public final class ImaAdsLoaderTest { private static final long CONTENT_PERIOD_DURATION_US = CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.EMPTY; + private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @@ -99,7 +101,7 @@ public final class ImaAdsLoaderTest { @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent; @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader; @Mock private ImaFactory mockImaFactory; - @Mock private AdPodInfo mockPrerollSingleAdAdPodInfo; + @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; private ViewGroup adViewGroup; @@ -195,12 +197,12 @@ public void startAndCallbacksAfterRelease() { // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_URI.toString()); + imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); - imaAdsLoader.playAd(); + imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.pauseAd(); - imaAdsLoader.stopAd(); + imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); + imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); @@ -215,11 +217,11 @@ public void playback_withPrerollAd_marksAdAsPlayed() { // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_URI.toString()); + imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(); + imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, @@ -233,7 +235,7 @@ public void playback_withPrerollAd_marksAdAsPlayed() { // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(); + imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. @@ -313,11 +315,11 @@ private void setupMocks() { when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(mockAdsLoader); - when(mockPrerollSingleAdAdPodInfo.getPodIndex()).thenReturn(0); - when(mockPrerollSingleAdAdPodInfo.getTotalAds()).thenReturn(1); - when(mockPrerollSingleAdAdPodInfo.getAdPosition()).thenReturn(1); + when(mockAdPodInfo.getPodIndex()).thenReturn(0); + when(mockAdPodInfo.getTotalAds()).thenReturn(1); + when(mockAdPodInfo.getAdPosition()).thenReturn(1); - when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockPrerollSingleAdAdPodInfo); + when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo); } private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) {