diff --git a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java
index 5bc8f9d0a9..2db20ec221 100644
--- a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java
+++ b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java
@@ -172,6 +172,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) {
@@ -629,6 +633,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--;
}
@@ -976,6 +981,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.
*
@@ -1088,15 +1136,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/libraries/common/src/main/java/androidx/media3/common/Timeline.java b/libraries/common/src/main/java/androidx/media3/common/Timeline.java
index 81459888a1..70e35e2431 100644
--- a/libraries/common/src/main/java/androidx/media3/common/Timeline.java
+++ b/libraries/common/src/main/java/androidx/media3/common/Timeline.java
@@ -825,6 +825,18 @@ 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.
+ */
+ @UnstableApi
+ 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/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java b/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java
index 6a07dce3dc..7e2d2a611d 100644
--- a/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java
+++ b/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java
@@ -487,6 +487,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() {
@@ -634,4 +693,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/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java
index 716e16ec3c..876e8284bc 100644
--- a/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java
+++ b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java
@@ -21,6 +21,7 @@ import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.media3.common.MediaItem.LiveConfiguration;
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder;
+import androidx.media3.test.utils.FakeMultiPeriodLiveTimeline;
import androidx.media3.test.utils.FakeTimeline;
import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition;
import androidx.media3.test.utils.TimelineAsserts;
@@ -431,6 +432,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/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java
index 7649d1bbe8..4affc2fd28 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java
@@ -663,7 +663,7 @@ import com.google.common.collect.ImmutableList;
playbackInfo.timeline,
playbackInfo.periodId,
playbackInfo.requestedContentPositionUs,
- playbackInfo.positionUs);
+ /* startPositionUs= */ playbackInfo.positionUs);
}
/**
@@ -689,69 +689,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