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 defaultPositionUs = + timeline.getPeriodPositionUs( + window, + period, + nextWindowIndex, + /* windowPositionUs= */ C.TIME_UNSET, + /* defaultPositionProjectionUs= */ max(0, bufferedDurationUs)); + if (defaultPositionUs == null) { return null; } - // We either start a new period in the same window or the first period in the next window. - 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 defaultPositionUs = - timeline.getPeriodPositionUs( - window, - period, - nextWindowIndex, - /* windowPositionUs= */ C.TIME_UNSET, - /* defaultPositionProjectionUs= */ max(0, bufferedDurationUs)); - if (defaultPositionUs == null) { - return null; - } - nextPeriodUid = defaultPositionUs.first; - startPositionUs = defaultPositionUs.second; - @Nullable MediaPeriodHolder nextMediaPeriodHolder = mediaPeriodHolder.getNext(); - if (nextMediaPeriodHolder != null && nextMediaPeriodHolder.uid.equals(nextPeriodUid)) { - windowSequenceNumber = nextMediaPeriodHolder.info.id.windowSequenceNumber; - } else { - windowSequenceNumber = nextWindowSequenceNumber++; - } + nextPeriodUid = defaultPositionUs.first; + startPositionUs = defaultPositionUs.second; + @Nullable MediaPeriodHolder nextMediaPeriodHolder = mediaPeriodHolder.getNext(); + if (nextMediaPeriodHolder != null && nextMediaPeriodHolder.uid.equals(nextPeriodUid)) { + windowSequenceNumber = nextMediaPeriodHolder.info.id.windowSequenceNumber; + } else { + windowSequenceNumber = nextWindowSequenceNumber++; } - - @Nullable - MediaPeriodId periodId = - resolveMediaPeriodIdForAds( - timeline, nextPeriodUid, startPositionUs, windowSequenceNumber, window, period); - if (contentPositionUs != C.TIME_UNSET - && mediaPeriodInfo.requestedContentPositionUs != C.TIME_UNSET) { - boolean isPrecedingPeriodAnAd = - timeline.getPeriodByUid(mediaPeriodInfo.id.periodUid, period).getAdGroupCount() > 0 - && period.isServerSideInsertedAdGroup(period.getRemovedAdGroupCount()); - // Handle the requested content position for period transitions within the same window. - if (periodId.isAd() && isPrecedingPeriodAnAd) { - // Propagate the requested position to the following ad period in the same window. - contentPositionUs = mediaPeriodInfo.requestedContentPositionUs; - } else if (isPrecedingPeriodAnAd) { - // Use the requested content position of the preceding ad period as the start position. - startPositionUs = mediaPeriodInfo.requestedContentPositionUs; - } - } - return getMediaPeriodInfo(timeline, periodId, contentPositionUs, startPositionUs); } + @Nullable + MediaPeriodId periodId = + resolveMediaPeriodIdForAds( + timeline, nextPeriodUid, startPositionUs, windowSequenceNumber, window, period); + if (contentPositionUs != C.TIME_UNSET + && mediaPeriodInfo.requestedContentPositionUs != C.TIME_UNSET) { + boolean precedingPeriodHasServerSideInsertedAds = + hasServerSideInsertedAds(mediaPeriodInfo.id.periodUid, timeline); + // Handle the requested content position for period transitions within the same window. + if (periodId.isAd() && precedingPeriodHasServerSideInsertedAds) { + // Propagate the requested position to the following ad period in the same window. + contentPositionUs = mediaPeriodInfo.requestedContentPositionUs; + } else if (precedingPeriodHasServerSideInsertedAds) { + // Use the requested content position of the preceding ad period as the start position. + startPositionUs = mediaPeriodInfo.requestedContentPositionUs; + } + } + return getMediaPeriodInfo(timeline, periodId, contentPositionUs, startPositionUs); + } + + /** + * Gets the {@link MediaPeriodInfo} that follows {@code mediaPeriodHolder} within the current + * period. + * + * @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 following {@link MediaPeriodInfo} in the current period. + */ + @Nullable + private MediaPeriodInfo getFollowingMediaPeriodInfoOfCurrentPeriod( + Timeline timeline, MediaPeriodHolder mediaPeriodHolder, long bufferedDurationUs) { + MediaPeriodInfo mediaPeriodInfo = mediaPeriodHolder.info; MediaPeriodId currentPeriodId = mediaPeriodInfo.id; timeline.getPeriodByUid(currentPeriodId.periodUid, period); if (currentPeriodId.isAd()) { @@ -800,6 +831,10 @@ import com.google.common.collect.ImmutableList; mediaPeriodInfo.requestedContentPositionUs, currentPeriodId.windowSequenceNumber); } + } else if (currentPeriodId.nextAdGroupIndex != C.INDEX_UNSET + && period.isLivePostrollPlaceholder(currentPeriodId.nextAdGroupIndex)) { + // The next ad group is the postroll placeholder. Ignore and try the next timeline period. + return getFirstMediaPeriodInfoOfNextPeriod(timeline, mediaPeriodHolder, bufferedDurationUs); } else { // Play the next ad group if it's still available. int adIndexInAdGroup = period.getFirstAdIndexToPlay(currentPeriodId.nextAdGroupIndex); @@ -824,13 +859,21 @@ import com.google.common.collect.ImmutableList; return getMediaPeriodInfoForAd( timeline, currentPeriodId.periodUid, - currentPeriodId.nextAdGroupIndex, + /* adGroupIndex= */ currentPeriodId.nextAdGroupIndex, adIndexInAdGroup, /* contentPositionUs= */ mediaPeriodInfo.durationUs, currentPeriodId.windowSequenceNumber); } } + private boolean hasServerSideInsertedAds(Object periodUid, Timeline timeline) { + int adGroupCount = timeline.getPeriodByUid(periodUid, period).getAdGroupCount(); + int firstAdGroupIndex = period.getRemovedAdGroupCount(); + return adGroupCount > 0 + && period.isServerSideInsertedAdGroup(firstAdGroupIndex) + && (adGroupCount > 1 || period.getAdGroupTimeUs(firstAdGroupIndex) != C.TIME_END_OF_SOURCE); + } + @Nullable private MediaPeriodInfo getMediaPeriodInfo( Timeline timeline, MediaPeriodId id, long requestedContentPositionUs, long startPositionUs) { @@ -879,7 +922,7 @@ import com.google.common.collect.ImmutableList; return new MediaPeriodInfo( id, startPositionUs, - contentPositionUs, + /* requestedContentPositionUs= */ contentPositionUs, /* endPositionUs= */ C.TIME_UNSET, durationUs, isFollowedByTransitionToSameStream, @@ -896,6 +939,8 @@ import com.google.common.collect.ImmutableList; long windowSequenceNumber) { timeline.getPeriodByUid(periodUid, period); int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(startPositionUs); + boolean isNextAdGroupPostrollPlaceholder = + nextAdGroupIndex != C.INDEX_UNSET && period.isLivePostrollPlaceholder(nextAdGroupIndex); boolean clipPeriodAtContentDuration = false; if (nextAdGroupIndex == C.INDEX_UNSET) { // Clip SSAI streams when at the end of the period. @@ -903,21 +948,23 @@ import com.google.common.collect.ImmutableList; period.getAdGroupCount() > 0 && period.isServerSideInsertedAdGroup(period.getRemovedAdGroupCount()); } else if (period.isServerSideInsertedAdGroup(nextAdGroupIndex) - && period.getAdGroupTimeUs(nextAdGroupIndex) == period.durationUs) { - if (period.hasPlayedAdGroup(nextAdGroupIndex)) { - // Clip period before played SSAI post-rolls. - nextAdGroupIndex = C.INDEX_UNSET; - clipPeriodAtContentDuration = true; - } + && period.getAdGroupTimeUs(nextAdGroupIndex) == period.durationUs + && period.hasPlayedAdGroup(nextAdGroupIndex)) { + // Clip period before played SSAI post-rolls. + nextAdGroupIndex = C.INDEX_UNSET; + clipPeriodAtContentDuration = true; } + MediaPeriodId id = new MediaPeriodId(periodUid, windowSequenceNumber, nextAdGroupIndex); boolean isLastInPeriod = isLastInPeriod(id); boolean isLastInWindow = isLastInWindow(timeline, id); boolean isLastInTimeline = isLastInTimeline(timeline, id, isLastInPeriod); boolean isFollowedByTransitionToSameStream = - nextAdGroupIndex != C.INDEX_UNSET && period.isServerSideInsertedAdGroup(nextAdGroupIndex); - long endPositionUs = nextAdGroupIndex != C.INDEX_UNSET + && period.isServerSideInsertedAdGroup(nextAdGroupIndex) + && !isNextAdGroupPostrollPlaceholder; + long endPositionUs = + nextAdGroupIndex != C.INDEX_UNSET && !isNextAdGroupPostrollPlaceholder ? period.getAdGroupTimeUs(nextAdGroupIndex) : clipPeriodAtContentDuration ? period.durationUs : C.TIME_UNSET; long durationUs = @@ -938,7 +985,7 @@ import com.google.common.collect.ImmutableList; isFollowedByTransitionToSameStream, isLastInPeriod, isLastInWindow, - isLastInTimeline); + /* isFinal= */ isLastInTimeline); } private boolean isLastInPeriod(MediaPeriodId id) { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java index 2ab4681030..87f420074a 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java @@ -27,6 +27,7 @@ import static org.robolectric.Shadows.shadowOf; import android.net.Uri; import android.os.Looper; import android.util.Pair; +import androidx.annotation.Nullable; import androidx.media3.common.AdPlaybackState; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -49,6 +50,7 @@ import androidx.media3.exoplayer.trackselection.TrackSelector; import androidx.media3.exoplayer.trackselection.TrackSelectorResult; import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.test.utils.FakeMediaSource; +import androidx.media3.test.utils.FakeMultiPeriodLiveTimeline; import androidx.media3.test.utils.FakeShuffleOrder; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; @@ -123,6 +125,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -131,6 +134,7 @@ public final class MediaPeriodQueueTest { setupAdTimeline(/* adGroupTimesUs...= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ C.TIME_UNSET, @@ -145,6 +149,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -160,15 +165,18 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 0); advance(); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, /* adDurationUs= */ C.TIME_UNSET, /* contentPositionUs= */ FIRST_AD_START_TIME_US, /* isFollowedByTransitionToSameStream= */ false); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ FIRST_AD_START_TIME_US, @@ -183,10 +191,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 1); advance(); setAdGroupLoaded(/* adGroupIndex= */ 1); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 1, AD_DURATION_US, /* contentPositionUs= */ SECOND_AD_START_TIME_US, @@ -201,6 +211,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -216,10 +227,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 0); advance(); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ FIRST_AD_START_TIME_US, @@ -234,10 +247,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 1); advance(); setAdGroupLoaded(/* adGroupIndex= */ 1); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 1, AD_DURATION_US, /* contentPositionUs= */ CONTENT_DURATION_US, @@ -252,6 +267,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -273,6 +289,7 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ C.TIME_UNSET, @@ -287,10 +304,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 1); advance(); setAdGroupLoaded(/* adGroupIndex= */ 1); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 1, AD_DURATION_US, /* contentPositionUs= */ FIRST_AD_START_TIME_US, @@ -305,10 +324,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 2); advance(); setAdGroupLoaded(/* adGroupIndex= */ 2); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 2, AD_DURATION_US, /* contentPositionUs= */ CONTENT_DURATION_US, @@ -323,6 +344,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -344,6 +366,7 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ C.TIME_UNSET, @@ -358,10 +381,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ true, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 1); advance(); setAdGroupLoaded(/* adGroupIndex= */ 1); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 1, AD_DURATION_US, /* contentPositionUs= */ FIRST_AD_START_TIME_US, @@ -376,10 +401,12 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ true, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 2); advance(); setAdGroupLoaded(/* adGroupIndex= */ 2); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 2, AD_DURATION_US, /* contentPositionUs= */ SECOND_AD_START_TIME_US, @@ -394,9 +421,223 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } + @Test + @SuppressWarnings("unchecked") + public void getNextMediaPeriodInfo_multiPeriodTimelineWithNoAdsAndNoPostrollPlaceholder() { + long contentPeriodDurationUs = FakeMultiPeriodLiveTimeline.PERIOD_DURATION_US; + long adPeriodDurationUs = FakeMultiPeriodLiveTimeline.AD_PERIOD_DURATION_US; + // Multi period timeline without ad playback state. + FakeMultiPeriodLiveTimeline multiPeriodLiveTimeline = + new FakeMultiPeriodLiveTimeline( + /* availabilityStartTimeUs= */ 0, + /* liveWindowDurationUs= */ 60_000_000, + /* nowUs= */ 110_000_000, + new boolean[] {false, true, true}, + /* isContentTimeline= */ true, + /* populateAds= */ false); + setupTimeline(multiPeriodLiveTimeline); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + /* periodUid= */ firstPeriodUid, + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ C.TIME_UNSET, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ contentPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-4[a]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ adPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-5[a]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ adPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-6[c]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ C.TIME_UNSET, // last period in live timeline + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ true, + /* isFinal= */ false, // a dynamic window never has a final period + /* nextAdGroupIndex= */ C.INDEX_UNSET); + advance(); + assertThat(getNextMediaPeriodInfo()).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + public void getNextMediaPeriodInfo_multiPeriodTimelineWithPostrollPlaceHolder() { + long contentPeriodDurationUs = FakeMultiPeriodLiveTimeline.PERIOD_DURATION_US; + long adPeriodDurationUs = FakeMultiPeriodLiveTimeline.AD_PERIOD_DURATION_US; + // Multi period timeline without ad playback state. + FakeMultiPeriodLiveTimeline multiPeriodLiveTimeline = + new FakeMultiPeriodLiveTimeline( + /* availabilityStartTimeUs= */ 0, + /* liveWindowDurationUs= */ 60_000_000, + /* nowUs= */ 110_000_000, + new boolean[] {false, true, true}, + /* isContentTimeline= */ false, + /* populateAds= */ false); + setupTimeline(multiPeriodLiveTimeline); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + /* periodUid= */ firstPeriodUid, + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ C.TIME_UNSET, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ contentPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ 0); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-4[a]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ adPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ 0); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-5[a]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ adPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ 0); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-6[c]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ C.TIME_UNSET, // last period in live timeline + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ 0); + advance(); + assertThat(getNextMediaPeriodInfo()).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + public void getNextMediaPeriodInfo_multiPeriodTimelineWithAdsAndWithPostRollPlaceHolder() { + long contentPeriodDurationUs = FakeMultiPeriodLiveTimeline.PERIOD_DURATION_US; + long adPeriodDurationUs = FakeMultiPeriodLiveTimeline.AD_PERIOD_DURATION_US; + FakeMultiPeriodLiveTimeline multiPeriodLiveTimeline = + new FakeMultiPeriodLiveTimeline( + /* availabilityStartTimeUs= */ 0, + /* liveWindowDurationUs= */ 60_000_000, + /* nowUs= */ 110_000_000, + new boolean[] {false, true, true}, + /* isContentTimeline= */ false, + /* populateAds= */ true); + setupTimeline(multiPeriodLiveTimeline); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + /* periodUid= */ firstPeriodUid, + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ C.TIME_UNSET, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ contentPeriodDurationUs, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ 0); + advance(); + assertNextMediaPeriodInfoIsAd( + /* periodUid= */ new Pair( + ((Pair) firstPeriodUid).first, "uid-4[a]"), + /* adGroupIndex= */ 0, + /* adDurationUs= */ adPeriodDurationUs, + /* contentPositionUs= */ 0, + /* isFollowedByTransitionToSameStream= */ true); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-4[a]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ 0, + /* durationUs= */ 0, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + advance(); + assertNextMediaPeriodInfoIsAd( + /* periodUid= */ new Pair( + ((Pair) firstPeriodUid).first, "uid-5[a]"), + /* adGroupIndex= */ 0, + /* adDurationUs= */ adPeriodDurationUs, + /* contentPositionUs= */ 0, + /* isFollowedByTransitionToSameStream= */ true); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-5[a]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ 0, + /* durationUs= */ 0, + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + new Pair(((Pair) firstPeriodUid).first, "uid-6[c]"), + /* startPositionUs= */ 0, + /* requestedContentPositionUs= */ 0, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ C.TIME_UNSET, // Last period in stream. + /* isFollowedByTransitionToSameStream= */ false, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* isFinal= */ false, + /* nextAdGroupIndex= */ 0); + advance(); + assertThat(getNextMediaPeriodInfo()).isNull(); + } + @Test public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaPeriodInfo() { setupAdTimeline(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE); @@ -409,6 +650,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 0); advance(); setAdGroupFailedToLoad(/* adGroupIndex= */ 0); @@ -421,6 +663,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -431,6 +674,7 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 1); setAdGroupLoaded(/* adGroupIndex= */ 2); assertNextMediaPeriodInfoIsAd( + firstPeriodUid, /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ C.TIME_UNSET, @@ -446,6 +690,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 1); setAdGroupPlayed(/* adGroupIndex= */ 1); clear(); @@ -458,6 +703,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ false, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ 2); setAdGroupPlayed(/* adGroupIndex= */ 2); clear(); @@ -470,6 +716,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -493,6 +740,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ false, + /* isFinal= */ false, /* nextAdGroupIndex= */ C.INDEX_UNSET); advance(); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( @@ -504,6 +752,7 @@ public final class MediaPeriodQueueTest { /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ true, + /* isFinal= */ true, /* nextAdGroupIndex= */ C.INDEX_UNSET); } @@ -1160,6 +1409,7 @@ public final class MediaPeriodQueueTest { /* staticMetadata= */ ImmutableList.of()); } + @Nullable private MediaPeriodInfo getNextMediaPeriodInfo() { return mediaPeriodQueue.getNextMediaPeriodInfo(/* rendererPositionUs= */ 0, playbackInfo); } @@ -1220,6 +1470,7 @@ public final class MediaPeriodQueueTest { boolean isFollowedByTransitionToSameStream, boolean isLastInPeriod, boolean isLastInWindow, + boolean isFinal, int nextAdGroupIndex) { assertThat(getNextMediaPeriodInfo()) .isEqualTo( @@ -1232,10 +1483,11 @@ public final class MediaPeriodQueueTest { isFollowedByTransitionToSameStream, isLastInPeriod, isLastInWindow, - /* isFinal= */ isLastInWindow)); + isFinal)); } private void assertNextMediaPeriodInfoIsAd( + Object periodUid, int adGroupIndex, long adDurationUs, long contentPositionUs, @@ -1244,12 +1496,12 @@ public final class MediaPeriodQueueTest { .isEqualTo( new MediaPeriodInfo( new MediaPeriodId( - firstPeriodUid, + periodUid, adGroupIndex, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 0), /* startPositionUs= */ 0, - contentPositionUs, + /* requestedContentPositionUs= */ contentPositionUs, /* endPositionUs= */ C.TIME_UNSET, adDurationUs, isFollowedByTransitionToSameStream, diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimeline.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimeline.java index cfc056c90d..26daf54472 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimeline.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimeline.java @@ -19,6 +19,7 @@ import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Util.msToUs; import static androidx.media3.common.util.Util.usToMs; +import androidx.media3.common.AdPlaybackState; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.Timeline; @@ -54,6 +55,8 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { private final MediaItem mediaItem; private final long availabilityStartTimeUs; private final long liveWindowDurationUs; + private final boolean isContentTimeline; + private final boolean populateAds; private long nowUs; private ImmutableList periods; @@ -66,19 +69,34 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { * @param nowUs The current time that determines the end of the live window. * @param adSequencePattern The repeating pattern of periods starting at {@code * availabilityStartTimeUs}. True is an ad period, and false a content period. + * @param isContentTimeline Whether the timeline is a content timeline without {@link + * AdPlaybackState}s. + * @param populateAds Whether to populate ads like after the ad event has been received. This + * parameter is ignored if the timeline is a content timeline. */ public FakeMultiPeriodLiveTimeline( long availabilityStartTimeUs, long liveWindowDurationUs, long nowUs, - boolean[] adSequencePattern) { + boolean[] adSequencePattern, + boolean isContentTimeline, + boolean populateAds) { checkArgument(nowUs - liveWindowDurationUs >= availabilityStartTimeUs); this.availabilityStartTimeUs = availabilityStartTimeUs; this.liveWindowDurationUs = liveWindowDurationUs; this.nowUs = nowUs; this.adSequencePattern = Arrays.copyOf(adSequencePattern, adSequencePattern.length); + this.isContentTimeline = isContentTimeline; + this.populateAds = populateAds; mediaItem = new MediaItem.Builder().build(); - periods = invalidate(availabilityStartTimeUs, liveWindowDurationUs, nowUs, adSequencePattern); + periods = + invalidate( + availabilityStartTimeUs, + liveWindowDurationUs, + nowUs, + adSequencePattern, + isContentTimeline, + populateAds); } /** Calculates the total duration of the given ad period sequence. */ @@ -93,7 +111,14 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { /** Advances the live window by the given duration, in microseconds. */ public void advanceNowUs(long durationUs) { nowUs += durationUs; - periods = invalidate(availabilityStartTimeUs, liveWindowDurationUs, nowUs, adSequencePattern); + periods = + invalidate( + availabilityStartTimeUs, + liveWindowDurationUs, + nowUs, + adSequencePattern, + isContentTimeline, + populateAds); } @Override @@ -136,7 +161,9 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { periodData.uid, /* windowIndex= */ 0, /* durationUs= */ periodIndex < getPeriodCount() - 1 ? periodData.durationUs : C.TIME_UNSET, - periodData.positionInWindowUs); + periodData.positionInWindowUs, + periodData.adPlaybackState, + /* isPlaceholder= */ false); return period; } @@ -159,7 +186,9 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { long availabilityStartTimeUs, long liveWindowDurationUs, long now, - boolean[] adSequencePattern) { + boolean[] adSequencePattern, + boolean isContentTimeline, + boolean populateAds) { long windowStartTimeUs = now - liveWindowDurationUs; int sequencePeriodCount = adSequencePattern.length; long sequenceDurationUs = calculateAdSequencePatternDurationUs(adSequencePattern); @@ -184,12 +213,28 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { while (lastPeriodStartTimeUs < now) { isAd = adSequencePattern[lastPeriodIndex % sequencePeriodCount]; long periodDurationUs = isAd ? AD_PERIOD_DURATION_US : PERIOD_DURATION_US; + long adPeriodDurationUs = periodDurationUs; + AdPlaybackState adPlaybackState = AdPlaybackState.NONE; + if (!isContentTimeline) { + adPlaybackState = new AdPlaybackState("adsId").withLivePostrollPlaceholderAppended(); + if (isAd && populateAds) { + adPlaybackState = + adPlaybackState + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0) + .withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs( + /* adGroupIndex= */ 0, /* adDurationsUs...= */ periodDurationUs); + adPeriodDurationUs = 0; + } + } liveWindow.add( new PeriodData( /* id= */ lastPeriodIndex++, + adPeriodDurationUs, + /* positionInWindowUs= */ lastPeriodStartTimeUs - windowStartTimeUs, isAd, - periodDurationUs, - /* positionInWindowUs= */ lastPeriodStartTimeUs - windowStartTimeUs)); + adPlaybackState)); lastPeriodStartTimeUs += periodDurationUs; } return liveWindow.build(); @@ -201,13 +246,20 @@ public class FakeMultiPeriodLiveTimeline extends Timeline { private final Object uid; private final long durationUs; private final long positionInWindowUs; + private final AdPlaybackState adPlaybackState; /** Creates an instance. */ - public PeriodData(int id, boolean isAd, long durationUs, long positionInWindowUs) { + public PeriodData( + int id, + long durationUs, + long positionInWindowUs, + boolean isAd, + AdPlaybackState adPlaybackState) { this.id = id; this.uid = "uid-" + id + "[" + (isAd ? "a" : "c") + "]"; this.durationUs = durationUs; this.positionInWindowUs = positionInWindowUs; + this.adPlaybackState = adPlaybackState; } } } diff --git a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimelineTest.java b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimelineTest.java index 2a1ac4a3b7..a76b7a9ff3 100644 --- a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimelineTest.java +++ b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeMultiPeriodLiveTimelineTest.java @@ -41,7 +41,9 @@ public class FakeMultiPeriodLiveTimelineTest { /* availabilityStartTimeUs= */ 0L, /* liveWindowDurationUs= */ 60_000_000L, /* nowUs= */ 60_000_000L, - adSequencePattern); + adSequencePattern, + /* isContentTimeline= */ true, + /* populateAds= */ false); Timeline.Period period = new Timeline.Period(); Timeline.Window window = new Timeline.Window(); @@ -67,6 +69,116 @@ public class FakeMultiPeriodLiveTimelineTest { adSequencePattern); } + @Test + public void newInstance_timelineWithAdsPopulated_correctPlaybackStates() { + boolean[] adSequencePattern = {false, true, true}; + FakeMultiPeriodLiveTimeline timeline = + new FakeMultiPeriodLiveTimeline( + /* availabilityStartTimeUs= */ 0L, + /* liveWindowDurationUs= */ 50_000_000L, + /* nowUs= */ 100_000_000L, + adSequencePattern, + /* isContentTimeline= */ false, + /* populateAds= */ true); + Timeline.Period period = new Timeline.Period(); + Timeline.Window window = new Timeline.Window(); + + assertThat(timeline.getPeriodCount()).isEqualTo(3); + assertThat(timeline.getWindow(0, window).windowStartTimeMs).isEqualTo(50_000L); + assertThat(timeline.getPeriod(0, period).uid).isEqualTo("uid-3[c]"); + assertThat(timeline.getPeriod(1, period).uid).isEqualTo("uid-4[a]"); + assertThat(timeline.getPeriod(1, period).getAdGroupCount()).isEqualTo(2); + assertThat(timeline.getPeriod(1, period).getAdGroupTimeUs(/* adGroupIndex= */ 0)).isEqualTo(0L); + assertThat(timeline.getPeriod(1, period).getAdCountInAdGroup(/* adGroupIndex= */ 0)) + .isEqualTo(1); + assertThat( + timeline + .getPeriod(1, period) + .getAdDurationUs(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)) + .isEqualTo(10_000_000L); + assertThat(timeline.getPeriod(1, period).getAdGroupTimeUs(/* adGroupIndex= */ 1)) + .isEqualTo(C.TIME_END_OF_SOURCE); + assertThat(timeline.getPeriod(1, period).getAdCountInAdGroup(/* adGroupIndex= */ 1)) + .isEqualTo(C.LENGTH_UNSET); + assertThat(timeline.getPeriod(1, period).isServerSideInsertedAdGroup(/* adGroupIndex= */ 1)) + .isTrue(); + assertThat(timeline.getPeriod(2, period).uid).isEqualTo("uid-5[a]"); + assertThat(timeline.getPeriod(2, period).getAdGroupCount()).isEqualTo(2); + assertThat(timeline.getPeriod(2, period).getAdGroupTimeUs(/* adGroupIndex= */ 0)).isEqualTo(0L); + assertThat(timeline.getPeriod(2, period).getAdCountInAdGroup(/* adGroupIndex= */ 0)) + .isEqualTo(1); + assertThat(timeline.getPeriod(2, period).isServerSideInsertedAdGroup(/* adGroupIndex= */ 1)) + .isTrue(); + assertThat( + timeline + .getPeriod(2, period) + .getAdDurationUs(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)) + .isEqualTo(10_000_000L); + assertThat(timeline.getPeriod(2, period).getAdGroupTimeUs(/* adGroupIndex= */ 1)) + .isEqualTo(C.TIME_END_OF_SOURCE); + assertThat(timeline.getPeriod(2, period).getAdCountInAdGroup(/* adGroupIndex= */ 1)) + .isEqualTo(C.LENGTH_UNSET); + assertExpectedWindow( + timeline, + calculateExpectedWindow( + /* availabilityStartTimeUs= */ 0L, + /* liveWindowDurationUs= */ 50_000_000L, + /* nowUs= */ 100_000_000L, + adSequencePattern), + adSequencePattern); + } + + @Test + public void newInstance_timelineWithAdsNotPopulated_correctPlaybackStates() { + boolean[] adSequencePattern = {false, true, true}; + FakeMultiPeriodLiveTimeline timeline = + new FakeMultiPeriodLiveTimeline( + /* availabilityStartTimeUs= */ 0L, + /* liveWindowDurationUs= */ 50_000_000L, + /* nowUs= */ 100_000_000L, + adSequencePattern, + /* isContentTimeline= */ false, + /* populateAds= */ false); + Timeline.Period period = new Timeline.Period(); + Timeline.Window window = new Timeline.Window(); + + // Assert that each period has no ads but a fake postroll ad group at the end. + assertThat(timeline.getPeriodCount()).isEqualTo(3); + assertThat(timeline.getWindow(0, window).windowStartTimeMs).isEqualTo(50_000L); + assertThat(timeline.getPeriod(0, period).uid).isEqualTo("uid-3[c]"); + assertThat(timeline.getPeriod(0, period).getAdGroupCount()).isEqualTo(1); + assertThat(timeline.getPeriod(0, period).getAdGroupTimeUs(/* adGroupIndex= */ 0)) + .isEqualTo(C.TIME_END_OF_SOURCE); + assertThat(timeline.getPeriod(0, period).getAdCountInAdGroup(/* adGroupIndex= */ 0)) + .isEqualTo(C.LENGTH_UNSET); + assertThat(timeline.getPeriod(0, period).isServerSideInsertedAdGroup(/* adGroupIndex= */ 0)) + .isTrue(); + assertThat(timeline.getPeriod(1, period).uid).isEqualTo("uid-4[a]"); + assertThat(timeline.getPeriod(1, period).getAdGroupCount()).isEqualTo(1); + assertThat(timeline.getPeriod(1, period).getAdGroupTimeUs(/* adGroupIndex= */ 0)) + .isEqualTo(C.TIME_END_OF_SOURCE); + assertThat(timeline.getPeriod(1, period).getAdCountInAdGroup(/* adGroupIndex= */ 0)) + .isEqualTo(C.LENGTH_UNSET); + assertThat(timeline.getPeriod(1, period).isServerSideInsertedAdGroup(/* adGroupIndex= */ 0)) + .isTrue(); + assertThat(timeline.getPeriod(2, period).uid).isEqualTo("uid-5[a]"); + assertThat(timeline.getPeriod(2, period).getAdGroupCount()).isEqualTo(1); + assertThat(timeline.getPeriod(2, period).getAdGroupTimeUs(/* adGroupIndex= */ 0)) + .isEqualTo(C.TIME_END_OF_SOURCE); + assertThat(timeline.getPeriod(2, period).getAdCountInAdGroup(/* adGroupIndex= */ 0)) + .isEqualTo(C.LENGTH_UNSET); + assertThat(timeline.getPeriod(2, period).isServerSideInsertedAdGroup(/* adGroupIndex= */ 0)) + .isTrue(); + assertExpectedWindow( + timeline, + calculateExpectedWindow( + /* availabilityStartTimeUs= */ 0L, + /* liveWindowDurationUs= */ 50_000_000L, + /* nowUs= */ 100_000_000L, + adSequencePattern), + adSequencePattern); + } + @Test public void advanceTimeUs_availabilitySinceStartOfUnixEpoch_correctPeriodsInLiveWindow() { boolean[] adSequencePattern = {false, true, true}; @@ -75,7 +187,9 @@ public class FakeMultiPeriodLiveTimelineTest { /* availabilityStartTimeUs= */ 0L, /* liveWindowDurationUs= */ 60_000_000L, /* nowUs= */ 60_000_123L, - adSequencePattern); + adSequencePattern, + /* isContentTimeline= */ true, + /* populateAds= */ false); Timeline.Period period = new Timeline.Period(); Timeline.Window window = new Timeline.Window(); @@ -154,7 +268,12 @@ public class FakeMultiPeriodLiveTimelineTest { FakeMultiPeriodLiveTimeline timeline = new FakeMultiPeriodLiveTimeline( - availabilityStartTimeUs, liveWindowDurationUs, nowUs, adSequencePattern); + availabilityStartTimeUs, + liveWindowDurationUs, + nowUs, + adSequencePattern, + /* isContentTimeline= */ true, + /* populateAds= */ false); assertThat(timeline.getWindow(0, new Timeline.Window()).windowStartTimeMs) .isEqualTo(Util.usToMs(nowUs - liveWindowDurationUs)); @@ -209,7 +328,12 @@ public class FakeMultiPeriodLiveTimelineTest { FakeMultiPeriodLiveTimeline timeline = new FakeMultiPeriodLiveTimeline( - availabilityStartTimeUs, liveWindowDurationUs, nowUs, adSequencePattern); + availabilityStartTimeUs, + liveWindowDurationUs, + nowUs, + adSequencePattern, + /* isContentTimeline= */ true, + /* populateAds= */ false); assertThat(timeline.getWindow(0, new Timeline.Window()).windowStartTimeMs) .isEqualTo(Util.usToMs(nowUs - liveWindowDurationUs)); @@ -227,7 +351,9 @@ public class FakeMultiPeriodLiveTimelineTest { /* availabilityStartTimeUs= */ 0L, /* liveWindowDurationUs= */ 120_000_000L, /* nowUs= */ 120_000_000L, - new boolean[] {false, true, true, true}); + new boolean[] {false, true, true, true}, + /* isContentTimeline= */ true, + /* populateAds= */ false); Timeline.Period period = new Timeline.Period(); Timeline.Window window = new Timeline.Window(); @@ -262,7 +388,9 @@ public class FakeMultiPeriodLiveTimelineTest { /* availabilityStartTimeUs= */ 0L, /* liveWindowDurationUs= */ 220_000_000L, /* nowUs= */ 250_000_000L, - new boolean[] {false, true, false, true, false}); + new boolean[] {false, true, false, true, false}, + /* isContentTimeline= */ true, + /* populateAds= */ false); assertThat(timeline.getPeriodCount()).isEqualTo(10); assertThat(timeline.getWindow(0, window).windowStartTimeMs).isEqualTo(30_000L);