diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b60ffc169c..c1795b6a12 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -205,6 +205,7 @@ ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue manipulation API. +* IMA extension: Add option to skip ads before the start position. * Demo app: Retain previous position in list of samples. * Add Guava dependency. 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 51c3894a19..ad6a34e84f 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 @@ -123,6 +123,7 @@ public final class ImaAdsLoader private int mediaLoadTimeoutMs; private int mediaBitrate; private boolean focusSkipButtonWhenAvailable; + private boolean playAdBeforeStartPosition; private ImaFactory imaFactory; /** @@ -137,6 +138,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; focusSkipButtonWhenAvailable = true; + playAdBeforeStartPosition = true; imaFactory = new DefaultImaFactory(); } @@ -250,6 +252,21 @@ public final class ImaAdsLoader 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; + } + @VisibleForTesting /* package */ Builder setImaFactory(ImaFactory imaFactory) { this.imaFactory = Assertions.checkNotNull(imaFactory); @@ -275,6 +292,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, adEventListener, imaFactory); @@ -298,6 +316,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, adEventListener, imaFactory); @@ -360,6 +379,7 @@ public final class ImaAdsLoader private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; + private final boolean playAdBeforeStartPosition; private final int mediaBitrate; @Nullable private final Set adUiElements; @Nullable private final AdEventListener adEventListener; @@ -465,6 +485,7 @@ public final class ImaAdsLoader /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, /* focusSkipButtonWhenAvailable= */ true, + /* playAdBeforeStartPosition= */ true, /* adUiElements= */ null, /* adEventListener= */ null, /* imaFactory= */ new DefaultImaFactory()); @@ -481,6 +502,7 @@ public final class ImaAdsLoader int mediaLoadTimeoutMs, int mediaBitrate, boolean focusSkipButtonWhenAvailable, + boolean playAdBeforeStartPosition, @Nullable Set adUiElements, @Nullable AdEventListener adEventListener, ImaFactory imaFactory) { @@ -492,6 +514,7 @@ public final class ImaAdsLoader this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = playAdBeforeStartPosition; this.adUiElements = adUiElements; this.adEventListener = adEventListener; this.imaFactory = imaFactory; @@ -671,15 +694,7 @@ public final class ImaAdsLoader @Override public void release() { pendingAdRequestContext = null; - if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); - if (adEventListener != null) { - adsManager.removeAdEventListener(adEventListener); - } - adsManager.destroy(); - adsManager = null; - } + destroyAdsManager(); adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; @@ -983,13 +998,18 @@ public final class ImaAdsLoader @Nullable AdsManager adsManager = this.adsManager; if (!isAdsManagerInitialized && adsManager != null) { isAdsManagerInitialized = true; - AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); - adsManager.init(adsRenderingSettings); - adsManager.start(); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } } + updateAdPlaybackState(); } handleTimelineOrPositionChanged(); } @@ -1063,7 +1083,11 @@ public final class ImaAdsLoader // Internal methods. - /** Configures ads rendering for starting playback, returning the settings for the IMA SDK. */ + /** + * 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() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); @@ -1083,26 +1107,42 @@ public final class ImaAdsLoader long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; long contentPositionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); - int adGroupIndexForPosition = + int adGroupForPositionIndex = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition != C.INDEX_UNSET) { - // 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. - if (hasMidrollAdGroups(adGroupTimesUs)) { + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + playAdBeforeStartPosition + || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs); + if (!playAdWhenStartingPlayback) { + adGroupForPositionIndex++; + } else if (hasMidrollAdGroups(adGroupTimesUs)) { + // 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 (adGroupIndexForPosition > 0) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { + if (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } - // 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. - long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; - long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; - double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; - adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + if (adGroupForPositionIndex == adGroupTimesUs.length) { + // 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 = adGroupTimesUs[adGroupForPositionIndex]; + long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1]; + 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; @@ -1552,6 +1592,18 @@ public final class ImaAdsLoader } } + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(this); + adsManager.removeAdEventListener(this); + if (adEventListener != null) { + adsManager.removeAdEventListener(adEventListener); + } + adsManager.destroy(); + adsManager = null; + } + } + /** Factory for objects provided by the IMA SDK. */ @VisibleForTesting /* package */ interface ImaFactory { 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 d92417c2de..906dfeb5fb 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 @@ -453,6 +453,171 @@ public final class ImaAdsLoaderTest { .withSkippedAdGroup(/* adGroupIndex= */ 0)); } + @Test + public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMidroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsManager).destroy(); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withSkippedAdGroup(/* adGroupIndex= */ 1)); + } + + @Test + public void + resumePlaybackBeforeSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); @@ -466,14 +631,21 @@ public final class ImaAdsLoaderTest { } private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { - fakeExoPlayer = new FakePlayer(); - adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); - when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); - imaAdsLoader = + setupPlayback( + contentTimeline, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI); + .buildForAdTag(TEST_URI)); + } + + private void setupPlayback( + Timeline contentTimeline, Float[] cuePoints, ImaAdsLoader imaAdsLoader) { + fakeExoPlayer = new FakePlayer(); + adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); + when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); }