Clip live periods that get a duration and end position

When an ad is inserted into a live period with an unset
duration, the live period needs to be wrapped with a
`ClippingMediaPeriod` and then actually be clipped to
the end position when the duration gets known. Without
this the renderers will never see an EOS which prevents
the reading/playing period from advancing.

In the case of a server side inserted ad on the other
hand, the actual clipping needs to be prevented to
keep the current behavior for SSAI streams. In an SSAI
stream, an ad inserted before the current position should
not produce a snap back to the newly inserted ad. This is
currently prevented in both places, when the updated
timeline is handled to not disable the renderers, and when
the `mediaPeriodQueue` updates the queued periods. This
behaviour is preserved to not create side effects of this
change.

PiperOrigin-RevId: 742642715
This commit is contained in:
bachinger 2025-04-01 04:44:04 -07:00 committed by Copybara-Service
parent f2d644b7b4
commit c95544156d
3 changed files with 168 additions and 39 deletions

View File

@ -590,10 +590,10 @@ import java.util.List;
newPeriodInfo.copyWithRequestedContentPositionUs(
oldPeriodInfo.requestedContentPositionUs);
if (!areDurationsCompatible(oldPeriodInfo.durationUs, newPeriodInfo.durationUs)) {
// The period duration changed. Remove all subsequent periods and check whether we read
// beyond the new duration.
if (oldPeriodInfo.durationUs != newPeriodInfo.durationUs) {
// The period duration changed.
periodHolder.updateClipping();
// Check whether we've read beyond the new duration.
long newDurationInRendererTime =
newPeriodInfo.durationUs == C.TIME_UNSET
? Long.MAX_VALUE
@ -607,12 +607,19 @@ import java.util.List;
periodHolder == prewarming
&& (maxRendererPrewarmingPositionUs == C.TIME_END_OF_SOURCE
|| maxRendererPrewarmingPositionUs >= newDurationInRendererTime);
// Remove all subsequent periods.
@MediaPeriodQueue.UpdatePeriodQueueResult int removeAfterResult = removeAfter(periodHolder);
if (removeAfterResult != 0) {
return removeAfterResult;
}
boolean isLivePeriodClippedForAd =
oldPeriodInfo.durationUs == C.TIME_UNSET
&& oldPeriodInfo.endPositionUs == C.TIME_END_OF_SOURCE
&& newPeriodInfo.endPositionUs != C.TIME_UNSET
&& newPeriodInfo.endPositionUs != C.TIME_END_OF_SOURCE;
int result = 0;
if (isReadingAndReadBeyondNewDuration) {
if (isReadingAndReadBeyondNewDuration
&& (oldPeriodInfo.durationUs != C.TIME_UNSET || isLivePeriodClippedForAd)) {
result |= UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD;
}
if (isPrewarmingAndReadBeyondNewDuration) {
@ -667,7 +674,7 @@ import java.util.List;
isFollowedByTransitionToSameStream,
isLastInPeriod,
isLastInWindow,
isLastInTimeline);
/* isFinal= */ isLastInTimeline);
}
/**
@ -1225,8 +1232,6 @@ import java.util.List;
boolean isPrecededByTransitionFromSameStream) {
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.
@ -1248,9 +1253,13 @@ import java.util.List;
boolean isFollowedByTransitionToSameStream =
nextAdGroupIndex != C.INDEX_UNSET
&& period.isServerSideInsertedAdGroup(nextAdGroupIndex)
&& !isNextAdGroupPostrollPlaceholder;
&& !period.isLivePostrollPlaceholder(nextAdGroupIndex);
boolean isFollowedByServerSidePostRollPlaceholder =
nextAdGroupIndex != C.INDEX_UNSET
&& period.isLivePostrollPlaceholder(nextAdGroupIndex)
&& period.isServerSideInsertedAdGroup(nextAdGroupIndex);
long endPositionUs =
nextAdGroupIndex != C.INDEX_UNSET && !isNextAdGroupPostrollPlaceholder
nextAdGroupIndex != C.INDEX_UNSET && !isFollowedByServerSidePostRollPlaceholder
? period.getAdGroupTimeUs(nextAdGroupIndex)
: clipPeriodAtContentDuration ? period.durationUs : C.TIME_UNSET;
long durationUs =

View File

@ -10595,7 +10595,6 @@ public final class ExoPlayerTest {
player.release();
}
@SuppressWarnings("deprecation") // Checking old volume commands
@Test
public void isCommandAvailable_isTrueForAvailableCommands() {
ExoPlayer player = parameterizeTestExoPlayerBuilder(new TestExoPlayerBuilder(context)).build();
@ -14139,35 +14138,24 @@ public final class ExoPlayerTest {
.build();
// Live stream timeline with unassigned next ad group.
AdPlaybackState initialAdPlaybackState =
new AdPlaybackState(
/* adsId= */ new Object(), /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
.withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(new long[][] {new long[] {10 * C.MICROS_PER_SECOND}});
new AdPlaybackState(/* adsId= */ new Object())
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ true);
// Updated timeline with ad group at 18 seconds.
long firstSampleTimeUs = TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
Timeline initialTimeline =
new FakeTimeline(
new TimelineWindowDefinition(
/* periodCount= */ 1,
/* id= */ 0,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* durationUs= */ C.TIME_UNSET,
initialAdPlaybackState));
TimelineWindowDefinition initialTimelineWindowDefinition =
new TimelineWindowDefinition.Builder()
.setDynamic(true)
.setDurationUs(C.TIME_UNSET)
.setUid(0)
.setAdPlaybackStates(ImmutableList.of(initialAdPlaybackState))
.build();
Timeline initialTimeline = new FakeTimeline(initialTimelineWindowDefinition);
AdPlaybackState updatedAdPlaybackState =
initialAdPlaybackState.withAdGroupTimeUs(
/* adGroupIndex= */ 0,
/* adGroupTimeUs= */ firstSampleTimeUs + 18 * C.MICROS_PER_SECOND);
Timeline updatedTimeline =
new FakeTimeline(
new TimelineWindowDefinition(
/* periodCount= */ 1,
/* id= */ 0,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* durationUs= */ C.TIME_UNSET,
updatedAdPlaybackState));
initialAdPlaybackState
.withNewAdGroup(0, firstSampleTimeUs + 18 * C.MICROS_PER_SECOND)
.withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, new long[] {10 * C.MICROS_PER_SECOND});
// Add samples to allow player to load and start playing (but no EOS as this is a live stream).
FakeMediaSource mediaSource =
new FakeMediaSource(
@ -14181,16 +14169,26 @@ public final class ExoPlayerTest {
// Set updated ad group once we reach 20 seconds, and then continue playing until 40 seconds.
player
.createMessage((message, payload) -> mediaSource.setNewSourceInfo(updatedTimeline))
.setPosition(20_000)
.createMessage(
(message, payload) ->
mediaSource.setNewSourceInfo(
new FakeTimeline(
initialTimelineWindowDefinition
.buildUpon()
.setAdPlaybackStates(ImmutableList.of(updatedAdPlaybackState))
.build())))
.setPosition(20_000L)
.send();
player.setMediaSource(mediaSource);
player.prepare();
playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 40_000);
playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 40_000L);
Timeline timeline = player.getCurrentTimeline();
player.release();
// Assert that the renderer hasn't been reset despite the inserted ad group.
assertThat(videoRenderer.get().positionResetCount).isEqualTo(1);
assertThat(timeline.getPeriod(0, new Timeline.Period()).adPlaybackState.adGroupCount)
.isEqualTo(2);
}
@Test

View File

@ -27,7 +27,9 @@ import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.D
import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;
import android.os.Looper;
@ -49,7 +51,9 @@ import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller;
import androidx.media3.exoplayer.source.SampleStream;
import androidx.media3.exoplayer.source.SinglePeriodTimeline;
import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource;
import androidx.media3.exoplayer.source.ads.SinglePeriodAdTimeline;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
@ -488,6 +492,38 @@ public final class MediaPeriodQueueTest {
/* nextAdGroupIndex= */ C.INDEX_UNSET);
}
@Test
public void
getNextMediaPeriodInfo_singlePeriodLiveTimelineWithPostRollPlaceholder_returnsCorrectMediaPeriodInfo() {
SinglePeriodTimeline liveContentTimeline =
new SinglePeriodTimeline(
C.TIME_UNSET,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* useLiveConfiguration= */ true,
/* manifest= */ null,
AD_MEDIA_ITEM);
adPlaybackState =
new AdPlaybackState(/* adsId= */ new Object())
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false);
SinglePeriodAdTimeline adTimeline =
new SinglePeriodAdTimeline(liveContentTimeline, adPlaybackState);
setupTimelines(adTimeline);
assertGetNextMediaPeriodInfoReturnsContentMediaPeriod(
/* periodUid= */ firstPeriodUid,
/* startPositionUs= */ 0,
/* requestedContentPositionUs= */ C.TIME_UNSET,
/* endPositionUs= */ C.TIME_END_OF_SOURCE,
/* durationUs= */ C.TIME_UNSET,
/* isPrecededByTransitionFromSameStream= */ false,
/* isFollowedByTransitionToSameStream= */ false,
/* isLastInPeriod= */ false,
/* isLastInWindow= */ false,
/* isFinal= */ false,
/* nextAdGroupIndex= */ 0);
}
@Test
@SuppressWarnings("unchecked")
public void getNextMediaPeriodInfo_multiPeriodTimelineWithNoAdsAndNoPostrollPlaceholder() {
@ -1194,6 +1230,92 @@ public final class MediaPeriodQueueTest {
assertThat(getQueueLength()).isEqualTo(3);
}
@Test
public void
updateQueuedPeriods_adInsertedIntoPeriodWithUnsetDuration_bufferedPositionEndOfSource()
throws InterruptedException, ExoPlaybackException {
// Initial setup enqueues the live period with only the placeholder ad in place.
adPlaybackState =
new AdPlaybackState(/* adsId= */ new Object())
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false);
SinglePeriodTimeline liveTimeline =
new SinglePeriodTimeline(
/* durationUs= */ C.TIME_UNSET,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* useLiveConfiguration= */ true,
/* manifest= */ null,
AD_MEDIA_ITEM);
setupTimelines(new SinglePeriodAdTimeline(liveTimeline, adPlaybackState));
enqueueNext();
// The period needs to be prepared to get the actual buffered position from it.
mediaPeriodQueue
.getLoadingPeriod()
.mediaPeriod
.prepare(
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
TrackGroupArray trackGroups = mediaPeriod.getTrackGroups();
ExoTrackSelection[] selection = new ExoTrackSelection[trackGroups.length];
SampleStream[] streams = new SampleStream[trackGroups.length];
for (int i = 0; i < streams.length; i++) {
streams[i] = mock(SampleStream.class);
}
try {
when(trackSelector.selectTracks(any(), any(), any(), any()))
.thenReturn(
new TrackSelectorResult(
new RendererConfiguration[0],
selection,
Tracks.EMPTY,
/* info= */ null));
} catch (ExoPlaybackException e) {
throw new RuntimeException(e);
}
mediaPeriod.selectTracks(
selection,
new boolean[trackGroups.length],
streams,
new boolean[trackGroups.length],
/* positionUs= */ 0L);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
},
0L);
// Ad inserted into timeline.
adPlaybackState =
adPlaybackState
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 30_000_123L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1);
updateAdTimeline(/* mediaSourceIndex= */ 0);
Timeline playlistTimeline = mediaSourceList.createTimeline();
mediaPeriodQueue
.getLoadingPeriod()
.handlePrepared(/* playbackSpeed= */ 1.0f, playlistTimeline, /* playWhenReady= */ false);
// Assume renderers have not yet read beyond the ad group timeUs.
long maxRendererReadPositionUs = Renderer.DEFAULT_DURATION_TO_PROGRESS_US + 30_000_122L;
@MediaPeriodQueue.UpdatePeriodQueueResult
int updateQueuedPeriodsResult =
mediaPeriodQueue.updateQueuedPeriods(
playlistTimeline,
/* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US,
/* maxRendererReadPositionUs= */ maxRendererReadPositionUs,
/* maxRendererPrewarmingPositionUs= */ maxRendererReadPositionUs);
assertThat(mediaPeriodQueue.getLoadingPeriod().mediaPeriod.getBufferedPositionUs())
.isEqualTo(C.TIME_END_OF_SOURCE);
assertThat(mediaPeriodQueue.getLoadingPeriod().info.durationUs).isEqualTo(30_000_123L);
assertThat(mediaPeriodQueue.getLoadingPeriod().info.startPositionUs).isEqualTo(0);
assertThat(mediaPeriodQueue.getLoadingPeriod().info.endPositionUs).isEqualTo(30_000_123L);
assertThat(mediaPeriodQueue.getLoadingPeriod().getBufferedPositionUs()).isEqualTo(30_000_123L);
assertThat(updateQueuedPeriodsResult).isEqualTo(0);
assertThat(getQueueLength()).isEqualTo(1);
}
@Test
public void
resolveMediaPeriodIdForAdsAfterPeriodPositionChange_behindAdPositionInSinglePeriodTimeline_resolvesToAd() {