diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fc80bc0c13..71c6449a8a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -33,6 +33,9 @@ * Remove `CastPlayer` specific playlist manipulation methods. Use `setMediaItems`, `addMediaItems`, `removeMediaItem` and `moveMediaItem` instead. +* Ad playback: + * Support changing ad break positions in the player logic + ([#5067](https://github.com/google/ExoPlayer/issues/5067). ### 2.14.0 (2021-05-13) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 5b207492d9..160cc1fa1a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source.ads; import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.common.collect.ObjectArrays.concat; import static java.lang.Math.max; import android.net.Uri; @@ -315,7 +316,7 @@ public final class AdPlaybackState implements Bundleable { new AdPlaybackState( /* adsId= */ null, /* adGroupTimesUs= */ new long[0], - /* adGroups= */ null, + /* adGroups= */ new AdGroup[0], /* adResumePositionUs= */ 0L, /* contentDurationUs= */ C.TIME_UNSET); @@ -353,7 +354,7 @@ public final class AdPlaybackState implements Bundleable { this( adsId, adGroupTimesUs, - /* adGroups= */ null, + createEmptyAdGroups(adGroupTimesUs.length), /* adResumePositionUs= */ 0, /* contentDurationUs= */ C.TIME_UNSET); } @@ -361,21 +362,15 @@ public final class AdPlaybackState implements Bundleable { private AdPlaybackState( @Nullable Object adsId, long[] adGroupTimesUs, - @Nullable AdGroup[] adGroups, + AdGroup[] adGroups, long adResumePositionUs, long contentDurationUs) { - checkArgument(adGroups == null || adGroups.length == adGroupTimesUs.length); + checkArgument(adGroups.length == adGroupTimesUs.length); this.adsId = adsId; this.adGroupTimesUs = adGroupTimesUs; this.adResumePositionUs = adResumePositionUs; this.contentDurationUs = contentDurationUs; adGroupCount = adGroupTimesUs.length; - if (adGroups == null) { - adGroups = new AdGroup[adGroupCount]; - for (int i = 0; i < adGroupCount; i++) { - adGroups[i] = new AdGroup(); - } - } this.adGroups = adGroups; } @@ -440,6 +435,30 @@ public final class AdPlaybackState implements Bundleable { return adGroup.states[adIndexInAdGroup] == AdPlaybackState.AD_STATE_ERROR; } + /** + * Returns an instance with the specified ad group times. + * + *

If the number of ad groups differs, ad groups are either removed or empty ad groups are + * added. + * + * @param adGroupTimesUs The new ad group times, in microseconds. + * @return The updated ad playback state. + */ + @CheckResult + public AdPlaybackState withAdGroupTimesUs(long[] adGroupTimesUs) { + AdGroup[] adGroups = + adGroupTimesUs.length < adGroupCount + ? Util.nullSafeArrayCopy(this.adGroups, adGroupTimesUs.length) + : adGroupTimesUs.length == adGroupCount + ? this.adGroups + : concat( + this.adGroups, + createEmptyAdGroups(adGroupTimesUs.length - adGroupCount), + AdGroup.class); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + /** * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. * The ad count must be greater than zero. @@ -679,12 +698,15 @@ public final class AdPlaybackState implements Bundleable { private static AdPlaybackState fromBundle(Bundle bundle) { @Nullable long[] adGroupTimesUs = bundle.getLongArray(keyForField(FIELD_AD_GROUP_TIMES_US)); + if (adGroupTimesUs == null) { + adGroupTimesUs = new long[0]; + } @Nullable ArrayList adGroupBundleList = bundle.getParcelableArrayList(keyForField(FIELD_AD_GROUPS)); @Nullable AdGroup[] adGroups; if (adGroupBundleList == null) { - adGroups = null; + adGroups = createEmptyAdGroups(adGroupTimesUs.length); } else { adGroups = new AdGroup[adGroupBundleList.size()]; for (int i = 0; i < adGroupBundleList.size(); i++) { @@ -696,14 +718,18 @@ public final class AdPlaybackState implements Bundleable { long contentDurationUs = bundle.getLong(keyForField(FIELD_CONTENT_DURATION_US), /* defaultValue= */ C.TIME_UNSET); return new AdPlaybackState( - /* adsId= */ null, - adGroupTimesUs == null ? new long[0] : adGroupTimesUs, - adGroups, - adResumePositionUs, - contentDurationUs); + /* adsId= */ null, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } private static String keyForField(@FieldNumber int field) { return Integer.toString(field, Character.MAX_RADIX); } + + private static AdGroup[] createEmptyAdGroups(int count) { + AdGroup[] adGroups = new AdGroup[count]; + for (int i = 0; i < count; i++) { + adGroups[i] = new AdGroup(); + } + return adGroups; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 948cbe2c69..23d8ca1be1 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -72,6 +72,38 @@ public class AdPlaybackStateTest { assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } + @Test + public void withAdGroupTimesUs_removingGroups_keepsRemainingGroups() { + AdPlaybackState state = + new AdPlaybackState(TEST_ADS_ID, new long[] {0, C.msToUs(10_000)}) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, TEST_URI); + + state = state.withAdGroupTimesUs(new long[] {C.msToUs(3_000)}); + + assertThat(state.adGroupCount).isEqualTo(1); + assertThat(state.adGroups[0].count).isEqualTo(2); + assertThat(state.adGroups[0].uris[1]).isSameInstanceAs(TEST_URI); + } + + @Test + public void withAdGroupTimesUs_addingGroups_keepsExistingGroups() { + AdPlaybackState state = + new AdPlaybackState(TEST_ADS_ID, new long[] {0, C.msToUs(10_000)}) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, TEST_URI) + .withSkippedAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0); + + state = state.withAdGroupTimesUs(new long[] {0, C.msToUs(3_000), C.msToUs(20_000)}); + + assertThat(state.adGroupCount).isEqualTo(3); + assertThat(state.adGroups[0].count).isEqualTo(2); + assertThat(state.adGroups[0].uris[1]).isSameInstanceAs(TEST_URI); + assertThat(state.adGroups[1].states[0]).isEqualTo(AdPlaybackState.AD_STATE_SKIPPED); + assertThat(state.adGroups[2].count).isEqualTo(C.INDEX_UNSET); + } + @Test public void getFirstAdIndexToPlayIsZero() { state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 3); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 29127725e2..eef91d7088 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -1799,6 +1799,7 @@ import java.util.concurrent.atomic.AtomicBoolean; // Update the new playing media period info if it already exists. if (periodHolder.info.id.equals(newPeriodId)) { periodHolder.info = queue.getUpdatedMediaPeriodInfo(timeline, periodHolder.info); + periodHolder.updateClipping(); } periodHolder = periodHolder.getNext(); } @@ -2127,7 +2128,9 @@ import java.util.concurrent.atomic.AtomicBoolean; Renderer renderer = renderers[i]; SampleStream sampleStream = readingPeriodHolder.sampleStreams[i]; if (renderer.getStream() != sampleStream - || (sampleStream != null && !renderer.hasReadStreamToEnd())) { + || (sampleStream != null + && !renderer.hasReadStreamToEnd() + && !hasFinishedReadingClippedContent(renderer, readingPeriodHolder))) { // The current reading period is still being read by at least one renderer. return false; } @@ -2135,6 +2138,17 @@ import java.util.concurrent.atomic.AtomicBoolean; return true; } + private boolean hasFinishedReadingClippedContent(Renderer renderer, MediaPeriodHolder reading) { + MediaPeriodHolder nextPeriod = reading.getNext(); + // We can advance the reading period early once the clipped content has been read beyond its + // clipped end time because we know there won't be any further samples. This shortcut is helpful + // in case the clipped end time was reduced and renderers already read beyond the new end time. + // But wait until the next period is actually prepared to allow a seamless transition. + return reading.info.id.nextAdGroupIndex != C.INDEX_UNSET + && nextPeriod.prepared + && renderer.getReadingPositionUs() >= nextPeriod.getStartPositionRendererTime(); + } + private void setAllRendererStreamsFinal(long streamEndPositionUs) { for (Renderer renderer : renderers) { if (renderer.getStream() != null) { @@ -2587,12 +2601,12 @@ import java.util.concurrent.atomic.AtomicBoolean; // Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and // the only change is that MediaPeriodId.nextAdGroupIndex increased. This postpones a potential // discontinuity until we reach the former next ad group position. - boolean oldAndNewPeriodIdAreSame = + boolean onlyNextAdGroupIndexIncreased = oldPeriodId.periodUid.equals(newPeriodUid) && !oldPeriodId.isAd() && !periodIdWithAds.isAd() && earliestCuePointIsUnchangedOrLater; - MediaPeriodId newPeriodId = oldAndNewPeriodIdAreSame ? oldPeriodId : periodIdWithAds; + MediaPeriodId newPeriodId = onlyNextAdGroupIndexIncreased ? oldPeriodId : periodIdWithAds; long periodPositionUs = contentPositionForAdResolutionUs; if (newPeriodId.isAd()) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java index d8569a544d..2e0d193ee3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -320,7 +320,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Releases the media period. No other method should be called after the release. */ public void release() { disableTrackSelectionsInResult(); - releaseMediaPeriod(info.endPositionUs, mediaSourceList, mediaPeriod); + releaseMediaPeriod(mediaSourceList, mediaPeriod); } /** @@ -357,6 +357,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return trackSelectorResult; } + /** Updates the clipping to {@link MediaPeriodInfo#endPositionUs} if required. */ + public void updateClipping() { + if (mediaPeriod instanceof ClippingMediaPeriod) { + long endPositionUs = + info.endPositionUs == C.TIME_UNSET ? C.TIME_END_OF_SOURCE : info.endPositionUs; + ((ClippingMediaPeriod) mediaPeriod).updateClipping(/* startUs= */ 0, endPositionUs); + } + } + private void enableTrackSelectionsInResult() { if (!isLoadingMediaPeriod()) { return; @@ -422,7 +431,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; long startPositionUs, long endPositionUs) { MediaPeriod mediaPeriod = mediaSourceList.createPeriod(id, allocator, startPositionUs); - if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { + if (endPositionUs != C.TIME_UNSET) { mediaPeriod = new ClippingMediaPeriod( mediaPeriod, /* enableInitialDiscontinuity= */ true, /* startUs= */ 0, endPositionUs); @@ -431,10 +440,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** Releases the given {@code mediaPeriod}, logging and suppressing any errors. */ - private static void releaseMediaPeriod( - long endPositionUs, MediaSourceList mediaSourceList, MediaPeriod mediaPeriod) { + private static void releaseMediaPeriod(MediaSourceList mediaSourceList, MediaPeriod mediaPeriod) { try { - if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { + if (mediaPeriod instanceof ClippingMediaPeriod) { mediaSourceList.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); } else { mediaSourceList.releasePeriod(mediaPeriod); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index d72eaa15db..7d01a0af0f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -351,6 +351,7 @@ import com.google.common.collect.ImmutableList; if (!areDurationsCompatible(oldPeriodInfo.durationUs, newPeriodInfo.durationUs)) { // The period duration changed. Remove all subsequent periods and check whether we read // beyond the new duration. + periodHolder.updateClipping(); long newDurationInRendererTime = newPeriodInfo.durationUs == C.TIME_UNSET ? Long.MAX_VALUE @@ -384,17 +385,21 @@ import com.google.common.collect.ImmutableList; boolean isLastInWindow = isLastInWindow(timeline, id); boolean isLastInTimeline = isLastInTimeline(timeline, id, isLastInPeriod); timeline.getPeriodByUid(info.id.periodUid, period); + long endPositionUs = + id.isAd() || id.nextAdGroupIndex == C.INDEX_UNSET + ? C.TIME_UNSET + : period.getAdGroupTimeUs(id.nextAdGroupIndex); long durationUs = id.isAd() ? period.getAdDurationUs(id.adGroupIndex, id.adIndexInAdGroup) - : (info.endPositionUs == C.TIME_UNSET || info.endPositionUs == C.TIME_END_OF_SOURCE + : (endPositionUs == C.TIME_UNSET || endPositionUs == C.TIME_END_OF_SOURCE ? period.getDurationUs() - : info.endPositionUs); + : endPositionUs); return new MediaPeriodInfo( id, info.startPositionUs, info.requestedContentPositionUs, - info.endPositionUs, + endPositionUs, durationUs, isLastInPeriod, isLastInWindow, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index 8eaf476328..6d84ce039c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -305,6 +305,31 @@ public final class MediaPeriodQueueTest { /* nextAdGroupIndex= */ C.INDEX_UNSET); } + @Test + public void + updateQueuedPeriods_withDurationChangeInPlayingContent_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { + setupAdTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + enqueueNext(); // Content before first ad. + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + + // Change position of first ad (= change duration of playing content before first ad). + updateAdPlaybackStateAndTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US - 2000); + setAdGroupLoaded(/* adGroupIndex= */ 0); + long maxRendererReadPositionUs = FIRST_AD_START_TIME_US - 3000; + boolean changeHandled = + mediaPeriodQueue.updateQueuedPeriods( + playbackInfo.timeline, /* rendererPositionUs= */ 0, maxRendererReadPositionUs); + + assertThat(changeHandled).isTrue(); + assertThat(getQueueLength()).isEqualTo(1); + assertThat(mediaPeriodQueue.getPlayingPeriod().info.endPositionUs) + .isEqualTo(FIRST_AD_START_TIME_US - 2000); + assertThat(mediaPeriodQueue.getPlayingPeriod().info.durationUs) + .isEqualTo(FIRST_AD_START_TIME_US - 2000); + } + @Test public void updateQueuedPeriods_withDurationChangeAfterReadingPeriod_handlesChangeAndRemovesPeriodsAfterChangedPeriod() {