diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ab7e9998c5..a2a439c52f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -52,8 +52,12 @@ ([#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.19.0, and migrate to new - preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). +* IMA extension: + * Upgrade to IMA SDK version 3.19.0, and migrate to new + preloading APIs + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). + * Add support for timing out ad preloading, to avoid playback getting + stuck if an ad group unexpectedly fails to load. ### 2.11.4 (2020-04-08) ### 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 0b9a8d747b..b151a595c0 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 @@ -102,11 +102,23 @@ public final class ImaAdsLoader /** 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 AdEventListener adEventListener; @Nullable private Set adUiElements; + private long adPreloadTimeoutMs; private int vastLoadTimeoutMs; private int mediaLoadTimeoutMs; private int mediaBitrate; @@ -120,6 +132,7 @@ public final class ImaAdsLoader */ public Builder(Context context) { this.context = Assertions.checkNotNull(context); + adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS; vastLoadTimeoutMs = TIMEOUT_UNSET; mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; @@ -165,6 +178,25 @@ public final class ImaAdsLoader 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) { + Assertions.checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0); + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + return this; + } + /** * Sets the VAST load timeout, in milliseconds. * @@ -238,6 +270,7 @@ public final class ImaAdsLoader adTagUri, imaSdkSettings, /* adsResponse= */ null, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -260,6 +293,7 @@ public final class ImaAdsLoader /* adTagUri= */ null, imaSdkSettings, adsResponse, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -291,7 +325,12 @@ public final class ImaAdsLoader * 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 END_OF_CONTENT_THRESHOLD_MS = 5000; + 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; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -317,6 +356,7 @@ public final class ImaAdsLoader @Nullable private final Uri adTagUri; @Nullable private final String adsResponse; + private final long adPreloadTimeoutMs; private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; @@ -398,6 +438,11 @@ public final class ImaAdsLoader private long pendingContentPositionMs; /** Whether {@link #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 IMA ads loader. @@ -415,6 +460,7 @@ public final class ImaAdsLoader adTagUri, /* imaSdkSettings= */ null, /* adsResponse= */ null, + /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS, /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, @@ -430,6 +476,7 @@ public final class ImaAdsLoader @Nullable Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings, @Nullable String adsResponse, + long adPreloadTimeoutMs, int vastLoadTimeoutMs, int mediaLoadTimeoutMs, int mediaBitrate, @@ -440,6 +487,7 @@ public final class ImaAdsLoader Assertions.checkArgument(adTagUri != null || adsResponse != null); this.adTagUri = adTagUri; this.adsResponse = adsResponse; + this.adPreloadTimeoutMs = adPreloadTimeoutMs; this.vastLoadTimeoutMs = vastLoadTimeoutMs; this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; @@ -473,6 +521,7 @@ public final class ImaAdsLoader 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; @@ -636,6 +685,7 @@ public final class ImaAdsLoader imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; + stopUpdatingAdProgress(); imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; @@ -737,6 +787,19 @@ public final class ImaAdsLoader if (DEBUG) { Log.d(TAG, "Content progress: " + 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(); + } + } + return videoProgressUpdate; } @@ -779,10 +842,15 @@ public final class ImaAdsLoader // Drop events after release. return; } - int adGroupIndex = getAdGroupIndex(adPodInfo); + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + 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; + } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = @@ -926,10 +994,34 @@ public final class ImaAdsLoader @Override public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + @Nullable Player player = this.player; if (adsManager == null || player == null) { return; } + if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) { + // Check whether we are waiting for an ad to preload. + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count != C.LENGTH_UNSET + && adGroup.count != 0 + && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + // An ad is available already so we must be buffering for some other reason. + return; + } + long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + long timeUntilAdMs = adGroupTimeMs - contentPositionMs; + if (timeUntilAdMs < adPreloadTimeoutMs) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { adsManager.pause(); return; @@ -939,6 +1031,7 @@ public final class ImaAdsLoader adsManager.resume(); return; } + handlePlayerStateChanged(playWhenReady, playbackState); } @@ -1219,6 +1312,10 @@ public final class ImaAdsLoader Assertions.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(); @@ -1233,19 +1330,11 @@ public final class ImaAdsLoader return; } - // TODO: Once IMA signals which ad group failed to load, clean up this code. - long playerPositionMs = player.getContentPosition(); - int adGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + // TODO: Once IMA signals which ad group failed to load, remove this call. + int adGroupIndex = getLoadingAdGroupIndex(); if (adGroupIndex == C.INDEX_UNSET) { - 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; - } + Log.w(TAG, "Unable to determine ad group index for ad group load error", error); + return; } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; @@ -1312,7 +1401,7 @@ public final class ImaAdsLoader if (!sentContentComplete && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && positionMs + END_OF_CONTENT_THRESHOLD_MS >= contentDurationMs) { + && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { Log.d(TAG, "adsLoader.contentComplete"); @@ -1350,7 +1439,7 @@ public final class ImaAdsLoader } } - private int getAdGroupIndex(AdPodInfo adPodInfo) { + private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { if (adPodInfo.getPodIndex() == -1) { // This is a postroll ad. return adPlaybackState.adGroupCount - 1; @@ -1366,6 +1455,23 @@ public final class ImaAdsLoader throw new IllegalStateException("Failed to find cue point"); } + /** + * 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() { + long playerPositionUs = + C.msToUs(getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period)); + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + playerPositionUs, C.msToUs(contentDurationMs)); + } + return adGroupIndex; + } + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; @@ -1379,7 +1485,9 @@ public final class ImaAdsLoader Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); return contentWindowPositionMs - - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } private static long[] getAdGroupTimesUs(List cuePoints) { 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 ddcd1ae483..18515f0625 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 @@ -48,7 +48,7 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.ext.ima.ImaAdsLoader.ImaFactory; -import com.google.android.exoplayer2.source.MaskingMediaSource; +import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; @@ -57,6 +57,7 @@ import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -73,22 +74,23 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.mockito.stubbing.Answer; +import org.robolectric.shadows.ShadowSystemClock; -/** Test for {@link ImaAdsLoader}. */ +/** Tests for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) public final class ImaAdsLoaderTest { private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final Timeline CONTENT_TIMELINE = new FakeTimeline( - new FakeTimeline.TimelineWindowDefinition( + new TimelineWindowDefinition( /* isSeekable= */ true, /* isDynamic= */ false, CONTENT_DURATION_US)); 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 long[][] ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -140,14 +142,14 @@ public final class ImaAdsLoaderTest { @Test public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test public void start_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); @@ -156,8 +158,8 @@ public final class ImaAdsLoaderTest { @Test public void start_withPlaceholderContent_initializedAdsLoader() { - Timeline placeholderTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); - setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); + setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); // We'll only create the rendering settings when initializing the ads loader. @@ -166,26 +168,26 @@ public final class ImaAdsLoaderTest { @Test public void start_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ 0) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void startAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test public void startAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -212,7 +214,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -245,14 +247,64 @@ public final class ImaAdsLoaderTest { .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance before the timeout and simulating polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US)); + } + + @Test + public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance past the timeout and simulate polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); @@ -282,31 +334,31 @@ public final class ImaAdsLoaderTest { List adsLoadedListeners = new ArrayList<>(); doAnswer( - invocation -> { - adsLoadedListeners.add(invocation.getArgument(0)); - return null; - }) + invocation -> { + adsLoadedListeners.add(invocation.getArgument(0)); + return null; + }) .when(mockAdsLoader) .addAdsLoadedListener(any()); doAnswer( - invocation -> { - adsLoadedListeners.remove(invocation.getArgument(0)); - return null; - }) + invocation -> { + adsLoadedListeners.remove(invocation.getArgument(0)); + return null; + }) .when(mockAdsLoader) .removeAdsLoadedListener(any()); when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); when(mockAdsManagerLoadedEvent.getUserRequestContext()) .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); doAnswer( - (Answer) - invocation -> { - for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : - adsLoadedListeners) { - listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); - } - return null; - }) + (Answer) + invocation -> { + for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : + adsLoadedListeners) { + listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); + } + return null; + }) .when(mockAdsLoader) .requestAds(mockAdsRequest); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index dee63d819e..783a452b1a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -360,6 +360,18 @@ public final class AdPlaybackState { return index < adGroupTimesUs.length ? index : C.INDEX_UNSET; } + /** Returns whether the specified ad has been marked as in {@link #AD_STATE_ERROR}. */ + public boolean isAdInErrorState(int adGroupIndex, int adIndexInAdGroup) { + if (adGroupIndex >= adGroups.length) { + return false; + } + AdGroup adGroup = adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET || adIndexInAdGroup >= adGroup.count) { + return false; + } + return adGroup.states[adIndexInAdGroup] == AdPlaybackState.AD_STATE_ERROR; + } + /** * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. * The ad count must be greater than zero. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 0cd27a90c0..bd4dd8876f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -64,7 +64,9 @@ public final class AdPlaybackStateTest { assertThat(state.adGroups[0].uris[0]).isNull(); assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)).isTrue(); assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } @Test