From c95544156db44282feaaead6686e0b3291ea06c8 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 1 Apr 2025 04:44:04 -0700 Subject: [PATCH] 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 --- .../media3/exoplayer/MediaPeriodQueue.java | 27 ++-- .../media3/exoplayer/ExoPlayerTest.java | 58 ++++----- .../exoplayer/MediaPeriodQueueTest.java | 122 ++++++++++++++++++ 3 files changed, 168 insertions(+), 39 deletions(-) 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 4b7540ed87..249d64bfa4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java @@ -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 = diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index f4e81f063d..071bb18acd 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -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 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 8575b0d065..84758b82ae 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java @@ -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() {