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() {