diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java
index 209b1de832..479c3ba506 100644
--- a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java
+++ b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java
@@ -820,6 +820,17 @@ public abstract class Timeline implements Bundleable {
: AD_STATE_UNAVAILABLE;
}
+ /**
+ * Returns whether the ad group at the given ad group index is a live postroll placeholder.
+ *
+ * @param adGroupIndex The ad group index.
+ * @return True if the ad group at the given index is a live postroll placeholder.
+ */
+ public boolean isLivePostrollPlaceholder(int adGroupIndex) {
+ return adGroupIndex == getAdGroupCount() - 1
+ && adPlaybackState.isLivePostrollPlaceholder(adGroupIndex);
+ }
+
/**
* Returns the position offset in the first unplayed ad at which to begin playback, in
* microseconds.
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 6054def4ff..61e4162322 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
@@ -173,6 +173,10 @@ public final class AdPlaybackState implements Bundleable {
return false;
}
+ private boolean isLivePostrollPlaceholder() {
+ return isServerSideInserted && timeUs == C.TIME_END_OF_SOURCE && count == C.LENGTH_UNSET;
+ }
+
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
@@ -630,6 +634,7 @@ public final class AdPlaybackState implements Bundleable {
// Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE.
// In practice we expect there to be few ad groups so the search shouldn't be expensive.
int index = adGroupCount - 1;
+ index -= isLivePostrollPlaceholder(index) ? 1 : 0;
while (index >= 0 && isPositionBeforeAdGroup(positionUs, periodDurationUs, index)) {
index--;
}
@@ -977,6 +982,49 @@ public final class AdPlaybackState implements Bundleable {
adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount);
}
+ /**
+ * Appends a live postroll placeholder ad group to the ad playback state.
+ *
+ *
Adding such a placeholder is only required for periods of server side ad insertion live
+ * streams.
+ *
+ *
When building the media period queue, it sets {@link MediaPeriodId#nextAdGroupIndex} of a
+ * content period to the index of the placeholder. However, the placeholder will not produce a
+ * period in the media period queue. This only happens when an actual ad group is inserted at the
+ * given {@code nextAdGroupIndex}. In this case the newly inserted ad group will be used to insert
+ * an ad period into the media period queue following the content period with the given {@link
+ * MediaPeriodId#nextAdGroupIndex}.
+ *
+ *
See {@link #endsWithLivePostrollPlaceHolder()} also.
+ *
+ * @return The new ad playback state instance ending with a live postroll placeholder.
+ */
+ public AdPlaybackState withLivePostrollPlaceholderAppended() {
+ return withNewAdGroup(adGroupCount, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE)
+ .withIsServerSideInserted(adGroupCount, true);
+ }
+
+ /**
+ * Returns whether the last ad group is a live postroll placeholder as inserted by {@link
+ * #withLivePostrollPlaceholderAppended()}.
+ *
+ * @return Whether the ad playback state ends with a live postroll placeholder.
+ */
+ public boolean endsWithLivePostrollPlaceHolder() {
+ int adGroupIndex = adGroupCount - 1;
+ return adGroupIndex >= 0 && isLivePostrollPlaceholder(adGroupIndex);
+ }
+
+ /**
+ * Whether the {@link AdGroup} at the given ad group index is a live postroll placeholder.
+ *
+ * @param adGroupIndex The ad group index.
+ * @return True if the ad group at the given index is a live postroll placeholder, false if not.
+ */
+ public boolean isLivePostrollPlaceholder(int adGroupIndex) {
+ return adGroupIndex == adGroupCount - 1 && getAdGroup(adGroupIndex).isLivePostrollPlaceholder();
+ }
+
/**
* Returns a copy of the ad playback state with the given ads ID.
*
@@ -1089,15 +1137,21 @@ public final class AdPlaybackState implements Bundleable {
private boolean isPositionBeforeAdGroup(
long positionUs, long periodDurationUs, int adGroupIndex) {
if (positionUs == C.TIME_END_OF_SOURCE) {
- // The end of the content is at (but not before) any postroll ad, and after any other ads.
+ // The end of the content is at (but not before) any postroll ad, and after any other ad.
return false;
}
- long adGroupPositionUs = getAdGroup(adGroupIndex).timeUs;
+ AdGroup adGroup = getAdGroup(adGroupIndex);
+ long adGroupPositionUs = adGroup.timeUs;
if (adGroupPositionUs == C.TIME_END_OF_SOURCE) {
- return periodDurationUs == C.TIME_UNSET || positionUs < periodDurationUs;
- } else {
- return positionUs < adGroupPositionUs;
+ // Handling postroll: The requested position is considered before a postroll when a)
+ // the period duration is unknown (last period in a live stream), or when b) the postroll is a
+ // placeholder in a period of a multi-period live window, or when c) the position actually is
+ // before the given period duration.
+ return periodDurationUs == C.TIME_UNSET
+ || (adGroup.isServerSideInserted && adGroup.count == C.LENGTH_UNSET)
+ || positionUs < periodDurationUs;
}
+ return positionUs < adGroupPositionUs;
}
// Bundleable implementation.
diff --git a/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java b/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java
index 4d8e305f2c..e3594deded 100644
--- a/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java
+++ b/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java
@@ -23,6 +23,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.MediaItem.LiveConfiguration;
import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder;
import com.google.android.exoplayer2.source.ads.AdPlaybackState;
+import com.google.android.exoplayer2.testutil.FakeMultiPeriodLiveTimeline;
import com.google.android.exoplayer2.testutil.FakeTimeline;
import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition;
import com.google.android.exoplayer2.testutil.TimelineAsserts;
@@ -432,6 +433,30 @@ public class TimelineTest {
/* expectedPeriod= */ period, /* actualPeriod= */ restoredPeriod);
}
+ @Test
+ public void periodIsLivePostrollPlaceholder_recognizesLivePostrollPlaceholder() {
+ FakeMultiPeriodLiveTimeline timeline =
+ new FakeMultiPeriodLiveTimeline(
+ /* availabilityStartTimeUs= */ 0,
+ /* liveWindowDurationUs= */ 60_000_000,
+ /* nowUs= */ 60_000_000,
+ /* adSequencePattern= */ new boolean[] {false, true, true},
+ /* isContentTimeline= */ false,
+ /* populateAds= */ true);
+
+ assertThat(timeline.getPeriodCount()).isEqualTo(4);
+ assertThat(
+ timeline
+ .getPeriod(/* periodIndex= */ 1, new Timeline.Period())
+ .isLivePostrollPlaceholder(/* adGroupIndex= */ 0))
+ .isFalse();
+ assertThat(
+ timeline
+ .getPeriod(/* periodIndex= */ 1, new Timeline.Period())
+ .isLivePostrollPlaceholder(/* adGroupIndex= */ 1))
+ .isTrue();
+ }
+
@SuppressWarnings("deprecation") // Populates the deprecated window.tag property.
private static Timeline.Window populateWindow(
@Nullable MediaItem mediaItem, @Nullable Object tag) {
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 63a41f83a3..3d75b27d7a 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
@@ -488,6 +488,65 @@ public class AdPlaybackStateTest {
assertThat(AdPlaybackState.AdGroup.CREATOR.fromBundle(adGroup.toBundle())).isEqualTo(adGroup);
}
+ @Test
+ public void withLivePostrollPlaceholderAppended_emptyAdPlaybackState_insertsPlaceholder() {
+ AdPlaybackState adPlaybackState =
+ new AdPlaybackState("adsId").withLivePostrollPlaceholderAppended();
+
+ assertThat(adPlaybackState.adGroupCount).isEqualTo(1);
+ assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 0).timeUs)
+ .isEqualTo(C.TIME_END_OF_SOURCE);
+ assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 0).count).isEqualTo(C.LENGTH_UNSET);
+ assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 0).isServerSideInserted).isTrue();
+ }
+
+ @Test
+ public void withLivePostrollPlaceholderAppended_withExistingAdGroups_appendsPlaceholder() {
+ AdPlaybackState adPlaybackState =
+ new AdPlaybackState("state", /* adGroupTimesUs...= */ 0L, 10_000_000L)
+ .withIsServerSideInserted(/* adGroupIndex= */ 0, true)
+ .withIsServerSideInserted(/* adGroupIndex= */ 1, true)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)
+ .withAdDurationsUs(/* adGroupIndex= */ 0, /* adDurationsUs...= */ 10_000_000L)
+ .withAdDurationsUs(/* adGroupIndex= */ 1, /* adDurationsUs...= */ 5_000_000L);
+
+ adPlaybackState = adPlaybackState.withLivePostrollPlaceholderAppended();
+
+ assertThat(adPlaybackState.adGroupCount).isEqualTo(3);
+ assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 2).timeUs)
+ .isEqualTo(C.TIME_END_OF_SOURCE);
+ assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 2).count).isEqualTo(C.LENGTH_UNSET);
+ assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 2).isServerSideInserted).isTrue();
+ }
+
+ @Test
+ public void endsWithLivePostrollPlaceHolder_withExistingAdGroups_postrollDetected() {
+ AdPlaybackState adPlaybackState =
+ new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L, 10_000_000L)
+ .withIsServerSideInserted(/* adGroupIndex= */ 0, true)
+ .withIsServerSideInserted(/* adGroupIndex= */ 1, true)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)
+ .withAdDurationsUs(/* adGroupIndex= */ 0, /* adDurationsUs...= */ 10_000_000L)
+ .withAdDurationsUs(/* adGroupIndex= */ 1, /* adDurationsUs...= */ 5_000_000L);
+
+ boolean endsWithLivePostrollPlaceHolder = adPlaybackState.endsWithLivePostrollPlaceHolder();
+
+ assertThat(endsWithLivePostrollPlaceHolder).isFalse();
+
+ adPlaybackState = adPlaybackState.withLivePostrollPlaceholderAppended();
+ endsWithLivePostrollPlaceHolder = adPlaybackState.endsWithLivePostrollPlaceHolder();
+
+ assertThat(endsWithLivePostrollPlaceHolder).isTrue();
+ }
+
+ @Test
+ public void endsWithLivePostrollPlaceHolder_emptyAdPlaybackState_postrollNotDetected() {
+ assertThat(AdPlaybackState.NONE.endsWithLivePostrollPlaceHolder()).isFalse();
+ assertThat(new AdPlaybackState("adsId").endsWithLivePostrollPlaceHolder()).isFalse();
+ }
+
@Test
public void
getAdGroupIndexAfterPositionUs_withClientSideInsertedAds_returnsNextAdGroupWithUnplayedAds() {
@@ -635,4 +694,103 @@ public class AdPlaybackStateTest {
/* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ 5000))
.isEqualTo(C.INDEX_UNSET);
}
+
+ @Test
+ public void
+ getAdGroupIndexAfterPositionUs_withServerSidePostrollPlaceholderForLive_placeholderAsNextAdGroupIndex() {
+ AdPlaybackState state =
+ new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 2000)
+ .withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)
+ .withLivePostrollPlaceholderAppended();
+
+ assertThat(
+ state.getAdGroupIndexAfterPositionUs(
+ /* positionUs= */ 1999, /* periodDurationUs= */ 5000))
+ .isEqualTo(0);
+ assertThat(
+ state.getAdGroupIndexAfterPositionUs(
+ /* positionUs= */ 2000, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(1);
+ assertThat(
+ state.getAdGroupIndexAfterPositionUs(
+ /* positionUs= */ 2000, /* periodDurationUs= */ 5000))
+ .isEqualTo(1);
+ assertThat(
+ state.getAdGroupIndexAfterPositionUs(
+ /* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexAfterPositionUs(
+ /* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ 5000))
+ .isEqualTo(C.INDEX_UNSET);
+ }
+
+ @Test
+ public void
+ getAdGroupIndexForPositionUs_withServerSidePostrollPlaceholderForLive_ignoresPlaceholder() {
+ AdPlaybackState state =
+ new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L, 5_000_000L, C.TIME_END_OF_SOURCE)
+ .withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
+ .withIsServerSideInserted(/* adGroupIndex= */ 1, /* isServerSideInserted= */ true)
+ .withIsServerSideInserted(/* adGroupIndex= */ 2, /* isServerSideInserted= */ true)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)
+ .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0);
+
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 4_999_999L, /* periodDurationUs= */ 10_000_000L))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 4_999_999L, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 5_000_000L, /* periodDurationUs= */ 10_000_000L))
+ .isEqualTo(1);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 5_000_000L, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(1);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ 10_000_000L))
+ .isEqualTo(1);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(1);
+ }
+
+ @Test
+ public void
+ getAdGroupIndexForPositionUs_withOnlyServerSidePostrollPlaceholderForLive_ignoresPlaceholder() {
+ AdPlaybackState state =
+ new AdPlaybackState("adsId", /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
+ .withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true);
+
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 5_000_000L, /* periodDurationUs= */ 10_000_000L))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 5_000_000L, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ 10_000_001L, /* periodDurationUs= */ 10_000_000L))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ 10_000_000L))
+ .isEqualTo(C.INDEX_UNSET);
+ assertThat(
+ state.getAdGroupIndexForPositionUs(
+ /* positionUs= */ C.TIME_END_OF_SOURCE, /* periodDurationUs= */ C.TIME_UNSET))
+ .isEqualTo(C.INDEX_UNSET);
+ }
}
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 0651dd8bf8..a02ba0afbc 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
@@ -661,7 +661,7 @@ import com.google.common.collect.ImmutableList;
playbackInfo.timeline,
playbackInfo.periodId,
playbackInfo.requestedContentPositionUs,
- playbackInfo.positionUs);
+ /* startPositionUs= */ playbackInfo.positionUs);
}
/**
@@ -687,69 +687,100 @@ import com.google.common.collect.ImmutableList;
// the start position for transitions to new windows.
long bufferedDurationUs =
mediaPeriodHolder.getRendererOffset() + mediaPeriodInfo.durationUs - rendererPositionUs;
- if (mediaPeriodInfo.isLastInTimelinePeriod) {
- int currentPeriodIndex = timeline.getIndexOfPeriod(mediaPeriodInfo.id.periodUid);
- int nextPeriodIndex =
- timeline.getNextPeriodIndex(
- currentPeriodIndex, period, window, repeatMode, shuffleModeEnabled);
- if (nextPeriodIndex == C.INDEX_UNSET) {
- // We can't create a next period yet.
+ return mediaPeriodInfo.isLastInTimelinePeriod
+ ? getFirstMediaPeriodInfoOfNextPeriod(timeline, mediaPeriodHolder, bufferedDurationUs)
+ : getFollowingMediaPeriodInfoOfCurrentPeriod(
+ timeline, mediaPeriodHolder, bufferedDurationUs);
+ }
+
+ /**
+ * Returns the first {@link MediaPeriodInfo} that follows the given {@linkplain MediaPeriodHolder
+ * media period holder}, or null if there is no following info. This can be the first info of the
+ * next period in the current (multi-period) window, or the first info in the next window in the
+ * timeline.
+ *
+ * @param timeline The timeline with period and window information
+ * @param mediaPeriodHolder The media period holder for which to get the following info.
+ * @param bufferedDurationUs The buffered duration, in microseconds.
+ * @return The first media period info of the next period in the timeline, or null.
+ */
+ @Nullable
+ private MediaPeriodInfo getFirstMediaPeriodInfoOfNextPeriod(
+ Timeline timeline, MediaPeriodHolder mediaPeriodHolder, long bufferedDurationUs) {
+ MediaPeriodInfo mediaPeriodInfo = mediaPeriodHolder.info;
+ int currentPeriodIndex = timeline.getIndexOfPeriod(mediaPeriodInfo.id.periodUid);
+ int nextPeriodIndex =
+ timeline.getNextPeriodIndex(
+ currentPeriodIndex, period, window, repeatMode, shuffleModeEnabled);
+ if (nextPeriodIndex == C.INDEX_UNSET) {
+ // We can't create a next period yet.
+ return null;
+ }
+ long startPositionUs = 0;
+ long contentPositionUs = 0;
+ int nextWindowIndex =
+ timeline.getPeriod(nextPeriodIndex, period, /* setIds= */ true).windowIndex;
+ Object nextPeriodUid = checkNotNull(period.uid);
+ long windowSequenceNumber = mediaPeriodInfo.id.windowSequenceNumber;
+ if (timeline.getWindow(nextWindowIndex, window).firstPeriodIndex == nextPeriodIndex) {
+ // We're starting to buffer a new window. When playback transitions to this window we'll
+ // want it to be from its default start position, so project the default start position
+ // forward by the duration of the buffer, and start buffering from this point.
+ contentPositionUs = C.TIME_UNSET;
+ @Nullable
+ Pair