From a72f04f9b00299e10de09d18146309656a5a9a02 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 11 Feb 2022 16:41:36 +0000 Subject: [PATCH] Resolve media period ids in multi-period windows Ignorable ad periods are skipped to resolve the media period id with the ad playback state of the resulting period. In case of a change in the period position un-played ad periods are rolled forward to be played. PiperOrigin-RevId: 428011116 --- .../androidx/media3/common/TimelineTest.java | 5 +- .../exoplayer/ExoPlayerImplInternal.java | 15 +- .../media3/exoplayer/MediaPeriodQueue.java | 111 ++++- .../media3/exoplayer/ExoPlayerTest.java | 465 +++++++++++++++++- .../exoplayer/MediaPeriodQueueTest.java | 319 +++++++++++- .../ImaServerSideAdInsertionMediaSource.java | 1 + .../media3/exoplayer/ima/ImaUtil.java | 5 +- .../exoplayer/ima/ImaAdsLoaderTest.java | 10 +- .../media3/exoplayer/ima/ImaUtilTest.java | 46 ++ .../test/utils/FakeMediaSourceFactory.java | 3 +- .../media3/test/utils/FakeTimeline.java | 159 +++++- .../media3/test/utils/FakeTimelineTest.java | 106 ++++ 12 files changed, 1177 insertions(+), 68 deletions(-) create mode 100644 libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeTimelineTest.java 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 b9452a1be4..f2de751193 100644 --- a/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java @@ -23,6 +23,7 @@ import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.media3.test.utils.TimelineAsserts; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import org.junit.Test; import org.junit.runner.RunWith; @@ -221,7 +222,7 @@ public class TimelineTest { /* durationUs= */ 2, /* defaultPositionUs= */ 22, /* windowOffsetInFirstPeriodUs= */ 222, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder().setMediaId("mediaId2").build()), new TimelineWindowDefinition( /* periodCount= */ 3, @@ -233,7 +234,7 @@ public class TimelineTest { /* durationUs= */ 3, /* defaultPositionUs= */ 33, /* windowOffsetInFirstPeriodUs= */ 333, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder().setMediaId("mediaId3").build())); Timeline restoredTimeline = Timeline.CREATOR.fromBundle(timeline.toBundle()); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java index cc32805042..14d3f9af17 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -1178,7 +1178,7 @@ import java.util.concurrent.atomic.AtomicBoolean; requestedContentPositionUs = seekPosition.windowPositionUs == C.TIME_UNSET ? C.TIME_UNSET : resolvedContentPositionUs; periodId = - queue.resolveMediaPeriodIdForAds( + queue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( playbackInfo.timeline, periodUid, resolvedContentPositionUs); if (periodId.isAd()) { playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period); @@ -1492,7 +1492,7 @@ import java.util.concurrent.atomic.AtomicBoolean; window, period, firstWindowIndex, /* windowPositionUs= */ C.TIME_UNSET); // Add ad metadata if any and propagate the window sequence number to new period id. MediaPeriodId firstPeriodId = - queue.resolveMediaPeriodIdForAds( + queue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( timeline, firstPeriodAndPositionUs.first, /* positionUs= */ 0); long positionUs = firstPeriodAndPositionUs.second; if (firstPeriodId.isAd()) { @@ -2354,7 +2354,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private PlaybackInfo handlePositionDiscontinuity( MediaPeriodId mediaPeriodId, long positionUs, - long contentPositionUs, + long requestedContentPositionUs, long discontinuityStartPositionUs, boolean reportDiscontinuity, @DiscontinuityReason int discontinuityReason) { @@ -2379,9 +2379,9 @@ import java.util.concurrent.atomic.AtomicBoolean; staticMetadata = extractMetadataFromTrackSelectionArray(trackSelectorResult.selections); // Ensure the media period queue requested content position matches the new playback info. if (playingPeriodHolder != null - && playingPeriodHolder.info.requestedContentPositionUs != contentPositionUs) { + && playingPeriodHolder.info.requestedContentPositionUs != requestedContentPositionUs) { playingPeriodHolder.info = - playingPeriodHolder.info.copyWithRequestedContentPositionUs(contentPositionUs); + playingPeriodHolder.info.copyWithRequestedContentPositionUs(requestedContentPositionUs); } } else if (!mediaPeriodId.equals(playbackInfo.periodId)) { // Reset previously kept track info if unprepared and the period changes. @@ -2395,7 +2395,7 @@ import java.util.concurrent.atomic.AtomicBoolean; return playbackInfo.copyWithNewPosition( mediaPeriodId, positionUs, - contentPositionUs, + requestedContentPositionUs, discontinuityStartPositionUs, getTotalBufferedDurationUs(), trackGroupArray, @@ -2668,7 +2668,8 @@ import java.util.concurrent.atomic.AtomicBoolean; // Ensure ad insertion metadata is up to date. MediaPeriodId periodIdWithAds = - queue.resolveMediaPeriodIdForAds(timeline, newPeriodUid, contentPositionForAdResolutionUs); + queue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, newPeriodUid, contentPositionForAdResolutionUs); boolean earliestCuePointIsUnchangedOrLater = periodIdWithAds.nextAdGroupIndex == C.INDEX_UNSET || (oldPeriodId.nextAdGroupIndex != C.INDEX_UNSET 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 774ff9d4cc..12edf0605c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer; +import static androidx.media3.common.util.Assertions.checkNotNull; import static java.lang.Math.max; import android.os.Handler; @@ -446,21 +447,7 @@ import com.google.common.collect.ImmutableList; Timeline timeline, Object periodUid, long positionUs) { long windowSequenceNumber = resolvePeriodIndexToWindowSequenceNumber(timeline, periodUid); return resolveMediaPeriodIdForAds( - timeline, periodUid, positionUs, windowSequenceNumber, period); - } - - // Internal methods. - - private void notifyQueueUpdate() { - ImmutableList.Builder builder = ImmutableList.builder(); - @Nullable MediaPeriodHolder period = playing; - while (period != null) { - builder.add(period.info.id); - period = period.getNext(); - } - @Nullable MediaPeriodId readingPeriodId = reading == null ? null : reading.info.id; - analyticsCollectorHandler.post( - () -> analyticsCollector.updateMediaPeriodQueueInfo(builder.build(), readingPeriodId)); + timeline, periodUid, positionUs, windowSequenceNumber, window, period); } /** @@ -481,8 +468,21 @@ import com.google.common.collect.ImmutableList; Object periodUid, long positionUs, long windowSequenceNumber, + Timeline.Window window, Timeline.Period period) { timeline.getPeriodByUid(periodUid, period); + timeline.getWindow(period.windowIndex, window); + int periodIndex = timeline.getIndexOfPeriod(periodUid); + // Skip ignorable server side inserted ad periods. + while ((period.durationUs == 0 + && period.getAdGroupCount() > 0 + && period.isServerSideInsertedAdGroup(period.getRemovedAdGroupCount()) + && period.getAdGroupIndexForPositionUs(0) == C.INDEX_UNSET) + && periodIndex++ < window.lastPeriodIndex) { + timeline.getPeriod(periodIndex, period, /* setIds= */ true); + periodUid = checkNotNull(period.uid); + } + timeline.getPeriodByUid(periodUid, period); int adGroupIndex = period.getAdGroupIndexForPositionUs(positionUs); if (adGroupIndex == C.INDEX_UNSET) { int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(positionUs); @@ -493,6 +493,55 @@ import com.google.common.collect.ImmutableList; } } + /** + * Resolves the specified timeline period and position to a {@link MediaPeriodId} that should be + * played after a period position change, returning an identifier for an ad group if one needs to + * be played before the specified position, or an identifier for a content media period if not. + * + * @param timeline The timeline the period is part of. + * @param periodUid The uid of the timeline period to play. + * @param positionUs The next content position in the period to play. + * @return The identifier for the first media period to play, taking into account unplayed ads. + */ + public MediaPeriodId resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + Timeline timeline, Object periodUid, long positionUs) { + long windowSequenceNumber = resolvePeriodIndexToWindowSequenceNumber(timeline, periodUid); + // Check for preceding ad periods in multi-period window. + timeline.getPeriodByUid(periodUid, period); + timeline.getWindow(period.windowIndex, window); + Object periodUidToPlay = periodUid; + boolean seenAdPeriod = false; + for (int i = timeline.getIndexOfPeriod(periodUid); i >= window.firstPeriodIndex; i--) { + timeline.getPeriod(/* periodIndex= */ i, period, /* setIds= */ true); + boolean isAdPeriod = period.getAdGroupCount() > 0; + seenAdPeriod |= isAdPeriod; + if (period.getAdGroupIndexForPositionUs(period.durationUs) != C.INDEX_UNSET) { + // Roll forward to preceding un-played ad period. + periodUidToPlay = checkNotNull(period.uid); + } + if (seenAdPeriod && (!isAdPeriod || period.durationUs != 0)) { + // Stop for any periods except un-played ads with no content. + break; + } + } + return resolveMediaPeriodIdForAds( + timeline, periodUidToPlay, positionUs, windowSequenceNumber, window, period); + } + + // Internal methods. + + private void notifyQueueUpdate() { + ImmutableList.Builder builder = ImmutableList.builder(); + @Nullable MediaPeriodHolder period = playing; + while (period != null) { + builder.add(period.info.id); + period = period.getNext(); + } + @Nullable MediaPeriodId readingPeriodId = reading == null ? null : reading.info.id; + analyticsCollectorHandler.post( + () -> analyticsCollector.updateMediaPeriodQueueInfo(builder.build(), readingPeriodId)); + } + /** * Resolves the specified period uid to a corresponding window sequence number. Either by reusing * the window sequence number of an existing matching media period or by creating a new window @@ -647,12 +696,12 @@ import com.google.common.collect.ImmutableList; // We can't create a next period yet. return null; } - - long startPositionUs; - long contentPositionUs; + // 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 = period.uid; + 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 @@ -672,20 +721,32 @@ import com.google.common.collect.ImmutableList; } nextPeriodUid = defaultPositionUs.first; startPositionUs = defaultPositionUs.second; - MediaPeriodHolder nextMediaPeriodHolder = mediaPeriodHolder.getNext(); + @Nullable MediaPeriodHolder nextMediaPeriodHolder = mediaPeriodHolder.getNext(); if (nextMediaPeriodHolder != null && nextMediaPeriodHolder.uid.equals(nextPeriodUid)) { windowSequenceNumber = nextMediaPeriodHolder.info.id.windowSequenceNumber; } else { windowSequenceNumber = nextWindowSequenceNumber++; } - } else { - // We're starting to buffer a new period within the same window. - startPositionUs = 0; - contentPositionUs = 0; } + + @Nullable MediaPeriodId periodId = resolveMediaPeriodIdForAds( - timeline, nextPeriodUid, startPositionUs, windowSequenceNumber, period); + 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); } 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 d99bbeeb2f..0d48e0d7cd 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -124,6 +124,7 @@ import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSourceEventListener; import androidx.media3.exoplayer.source.SinglePeriodTimeline; +import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; import androidx.media3.exoplayer.upstream.Allocation; import androidx.media3.exoplayer.upstream.Allocator; @@ -4984,6 +4985,436 @@ public final class ExoPlayerTest { runUntilPlaybackState(player, Player.STATE_ENDED); } + @Test + public void seekTo_beyondSSAIMidRolls_seekAdjustedAndRequestedContentPositionKept() + throws Exception { + ArgumentCaptor oldPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor newPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor reasonArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + FakeTimeline adTimeline = + FakeTimeline.createMultiPeriodAdTimeline( + "windowId", + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ false, + true, + true, + false); + Listener listener = mock(Listener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(listener); + AtomicReference sourceReference = new AtomicReference<>(); + sourceReference.set( + new ServerSideAdInsertionMediaSource( + new FakeMediaSource(adTimeline), + contentTimeline -> { + sourceReference + .get() + .setAdPlaybackStates(adTimeline.getAdPlaybackStates(/* windowIndex= */ 0)); + return true; + })); + player.setMediaSource(sourceReference.get()); + player.pause(); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.seekTo(/* positionMs= */ 4000); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + verify(listener, times(6)) + .onPositionDiscontinuity( + oldPositionArgumentCaptor.capture(), + newPositionArgumentCaptor.capture(), + reasonArgumentCaptor.capture()); + List oldPositions = oldPositionArgumentCaptor.getAllValues(); + List newPositions = newPositionArgumentCaptor.getAllValues(); + List reasons = reasonArgumentCaptor.getAllValues(); + assertThat(reasons).containsExactly(1, 2, 0, 0, 0, 0).inOrder(); + // seek discontinuities + assertThat(oldPositions.get(0).periodIndex).isEqualTo(0); + assertThat(oldPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(0).periodIndex).isEqualTo(3); + assertThat(newPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(0).positionMs).isEqualTo(4000); + // seek adjustment + assertThat(oldPositions.get(1).periodIndex).isEqualTo(3); + assertThat(oldPositions.get(1).adGroupIndex).isEqualTo(-1); + assertThat(oldPositions.get(1).positionMs).isEqualTo(4000); + assertThat(newPositions.get(1).periodIndex).isEqualTo(1); + assertThat(newPositions.get(1).adGroupIndex).isEqualTo(0); + assertThat(newPositions.get(1).adIndexInAdGroup).isEqualTo(0); + assertThat(newPositions.get(1).positionMs).isEqualTo(0); + assertThat(newPositions.get(1).contentPositionMs).isEqualTo(4000); + // auto transition from ad to end of period + assertThat(oldPositions.get(2).periodIndex).isEqualTo(1); + assertThat(oldPositions.get(2).adGroupIndex).isEqualTo(0); + assertThat(oldPositions.get(2).adIndexInAdGroup).isEqualTo(0); + assertThat(oldPositions.get(2).positionMs).isEqualTo(2500); + assertThat(oldPositions.get(2).contentPositionMs).isEqualTo(4000); + assertThat(newPositions.get(2).periodIndex).isEqualTo(1); + assertThat(newPositions.get(2).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(2).positionMs).isEqualTo(2500); + // auto transition to next ad period + assertThat(oldPositions.get(3).periodIndex).isEqualTo(1); + assertThat(oldPositions.get(3).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(3).periodIndex).isEqualTo(2); + assertThat(newPositions.get(3).adGroupIndex).isEqualTo(0); + assertThat(newPositions.get(3).adIndexInAdGroup).isEqualTo(0); + assertThat(newPositions.get(3).contentPositionMs).isEqualTo(4000); + // auto transition from ad to end of period + assertThat(oldPositions.get(4).periodIndex).isEqualTo(2); + assertThat(oldPositions.get(4).adGroupIndex).isEqualTo(0); + assertThat(oldPositions.get(4).adIndexInAdGroup).isEqualTo(0); + assertThat(newPositions.get(4).periodIndex).isEqualTo(2); + assertThat(newPositions.get(4).adGroupIndex).isEqualTo(-1); + // auto transition to final content period with seek position + assertThat(oldPositions.get(5).periodIndex).isEqualTo(2); + assertThat(oldPositions.get(5).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(5).periodIndex).isEqualTo(3); + assertThat(newPositions.get(5).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(5).contentPositionMs).isEqualTo(4000); + } + + @Test + public void seekTo_beyondSSAIMidRollsConsecutiveContentPeriods_seekAdjusted() throws Exception { + ArgumentCaptor oldPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor newPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor reasonArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + FakeTimeline adTimeline = + FakeTimeline.createMultiPeriodAdTimeline( + "windowId", + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ false, + true, + false, + false); + Listener listener = mock(Listener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(listener); + AtomicReference sourceReference = new AtomicReference<>(); + sourceReference.set( + new ServerSideAdInsertionMediaSource( + new FakeMediaSource(adTimeline), + contentTimeline -> { + sourceReference + .get() + .setAdPlaybackStates(adTimeline.getAdPlaybackStates(/* windowIndex= */ 0)); + return true; + })); + player.setMediaSource(sourceReference.get()); + player.pause(); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.seekTo(/* positionMs= */ 7000); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + verify(listener, times(5)) + .onPositionDiscontinuity( + oldPositionArgumentCaptor.capture(), + newPositionArgumentCaptor.capture(), + reasonArgumentCaptor.capture()); + List oldPositions = oldPositionArgumentCaptor.getAllValues(); + List newPositions = newPositionArgumentCaptor.getAllValues(); + List reasons = reasonArgumentCaptor.getAllValues(); + assertThat(reasons).containsExactly(1, 2, 0, 0, 0).inOrder(); + // seek + assertThat(oldPositions.get(0).periodIndex).isEqualTo(0); + assertThat(oldPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(0).periodIndex).isEqualTo(3); + assertThat(newPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(0).positionMs).isEqualTo(7000); + // seek adjustment + assertThat(oldPositions.get(1).periodIndex).isEqualTo(3); + assertThat(oldPositions.get(1).adGroupIndex).isEqualTo(-1); + assertThat(oldPositions.get(1).positionMs).isEqualTo(7000); + assertThat(newPositions.get(1).periodIndex).isEqualTo(1); + assertThat(newPositions.get(1).adGroupIndex).isEqualTo(0); + assertThat(newPositions.get(1).positionMs).isEqualTo(0); + } + + @Test + public void seekTo_beforeSSAIMidRolls_requestedContentPositionNotPropagatedIntoAds() + throws Exception { + ArgumentCaptor oldPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor newPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor reasonArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + FakeTimeline adTimeline = + FakeTimeline.createMultiPeriodAdTimeline( + "windowId", + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ false, + true, + true, + false); + Listener listener = mock(Listener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(listener); + AtomicReference sourceReference = new AtomicReference<>(); + sourceReference.set( + new ServerSideAdInsertionMediaSource( + new FakeMediaSource(adTimeline), + contentTimeline -> { + sourceReference + .get() + .setAdPlaybackStates(adTimeline.getAdPlaybackStates(/* windowIndex= */ 0)); + return true; + })); + player.setMediaSource(sourceReference.get()); + player.pause(); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player.play(); + + player.seekTo(1600); + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + verify(listener, times(6)) + .onPositionDiscontinuity( + oldPositionArgumentCaptor.capture(), + newPositionArgumentCaptor.capture(), + reasonArgumentCaptor.capture()); + List oldPositions = oldPositionArgumentCaptor.getAllValues(); + List newPositions = newPositionArgumentCaptor.getAllValues(); + List reasons = reasonArgumentCaptor.getAllValues(); + assertThat(reasons).containsExactly(1, 0, 0, 0, 0, 0).inOrder(); + // seek discontinuity + assertThat(oldPositions.get(0).periodIndex).isEqualTo(0); + assertThat(newPositions.get(0).periodIndex).isEqualTo(0); + assertThat(newPositions.get(0).positionMs).isEqualTo(1600); + assertThat(newPositions.get(0).contentPositionMs).isEqualTo(1600); + // auto discontinuities through ads has correct content position that is not the seek position. + assertThat(newPositions.get(1).periodIndex).isEqualTo(1); + assertThat(newPositions.get(1).adGroupIndex).isEqualTo(0); + assertThat(newPositions.get(1).adIndexInAdGroup).isEqualTo(0); + assertThat(newPositions.get(1).positionMs).isEqualTo(0); + assertThat(newPositions.get(1).contentPositionMs).isEqualTo(2500); + assertThat(newPositions.get(2).contentPositionMs).isEqualTo(2500); + assertThat(newPositions.get(3).contentPositionMs).isEqualTo(2500); + assertThat(newPositions.get(4).contentPositionMs).isEqualTo(2500); + // Content resumes at expected position that is not the seek position. + assertThat(newPositions.get(5).periodIndex).isEqualTo(3); + assertThat(newPositions.get(5).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(5).positionMs).isEqualTo(2500); + assertThat(newPositions.get(5).contentPositionMs).isEqualTo(2500); + } + + @Test + public void seekTo_toSAIMidRolls_playsMidRolls() throws Exception { + ArgumentCaptor oldPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor newPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor reasonArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + FakeTimeline adTimeline = + FakeTimeline.createMultiPeriodAdTimeline( + "windowId", + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ false, + true, + true, + false); + Listener listener = mock(Listener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(listener); + AtomicReference sourceReference = new AtomicReference<>(); + sourceReference.set( + new ServerSideAdInsertionMediaSource( + new FakeMediaSource(adTimeline), + contentTimeline -> { + sourceReference + .get() + .setAdPlaybackStates(adTimeline.getAdPlaybackStates(/* windowIndex= */ 0)); + return true; + })); + player.setMediaSource(sourceReference.get()); + player.pause(); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player.seekTo(2500); + player.play(); + + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + verify(listener, times(6)) + .onPositionDiscontinuity( + oldPositionArgumentCaptor.capture(), + newPositionArgumentCaptor.capture(), + reasonArgumentCaptor.capture()); + List oldPositions = oldPositionArgumentCaptor.getAllValues(); + List newPositions = newPositionArgumentCaptor.getAllValues(); + List reasons = reasonArgumentCaptor.getAllValues(); + assertThat(reasons).containsExactly(1, 2, 0, 0, 0, 0).inOrder(); + // seek discontinuity + assertThat(oldPositions.get(0).periodIndex).isEqualTo(0); + assertThat(oldPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(0).periodIndex).isEqualTo(1); + assertThat(newPositions.get(0).adGroupIndex).isEqualTo(-1); + // seek adjustment discontinuity + assertThat(oldPositions.get(1).periodIndex).isEqualTo(1); + assertThat(oldPositions.get(1).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(1).periodIndex).isEqualTo(1); + assertThat(newPositions.get(1).adGroupIndex).isEqualTo(0); + // auto transition to last frame of first ad period + assertThat(oldPositions.get(2).periodIndex).isEqualTo(1); + assertThat(oldPositions.get(2).adGroupIndex).isEqualTo(0); + assertThat(newPositions.get(2).periodIndex).isEqualTo(1); + assertThat(newPositions.get(2).adGroupIndex).isEqualTo(-1); + // auto transition to second ad period + assertThat(oldPositions.get(3).periodIndex).isEqualTo(1); + assertThat(oldPositions.get(3).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(3).periodIndex).isEqualTo(2); + assertThat(newPositions.get(3).adGroupIndex).isEqualTo(0); + // auto transition to last frame of second ad period + assertThat(oldPositions.get(4).periodIndex).isEqualTo(2); + assertThat(oldPositions.get(4).adGroupIndex).isEqualTo(0); + assertThat(newPositions.get(4).periodIndex).isEqualTo(2); + assertThat(newPositions.get(4).adGroupIndex).isEqualTo(-1); + // auto transition to the final content period + assertThat(oldPositions.get(5).periodIndex).isEqualTo(2); + assertThat(oldPositions.get(5).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(5).periodIndex).isEqualTo(3); + assertThat(newPositions.get(5).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(5).positionMs).isEqualTo(2500); + assertThat(newPositions.get(5).contentPositionMs).isEqualTo(2500); + } + + @Test + public void seekTo_toPlayedSAIMidRolls_requestedContentPositionNotPropagatedIntoAds() + throws Exception { + ArgumentCaptor oldPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor newPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor reasonArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + FakeTimeline adTimeline = + FakeTimeline.createMultiPeriodAdTimeline( + "windowId", + /* numberOfPlayedAds= */ 2, + /* isAdPeriodFlags...= */ false, + true, + true, + false); + Listener listener = mock(Listener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(listener); + AtomicReference sourceReference = new AtomicReference<>(); + sourceReference.set( + new ServerSideAdInsertionMediaSource( + new FakeMediaSource(adTimeline), + contentTimeline -> { + sourceReference + .get() + .setAdPlaybackStates(adTimeline.getAdPlaybackStates(/* windowIndex= */ 0)); + return true; + })); + player.setMediaSource(sourceReference.get()); + player.pause(); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player.seekTo(2500); + player.play(); + + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + verify(listener, times(1)) + .onPositionDiscontinuity( + oldPositionArgumentCaptor.capture(), + newPositionArgumentCaptor.capture(), + reasonArgumentCaptor.capture()); + List oldPositions = oldPositionArgumentCaptor.getAllValues(); + List newPositions = newPositionArgumentCaptor.getAllValues(); + List reasons = reasonArgumentCaptor.getAllValues(); + assertThat(reasons).containsExactly(1).inOrder(); + // seek discontinuity + assertThat(oldPositions.get(0).periodIndex).isEqualTo(0); + assertThat(oldPositions.get(0).adGroupIndex).isEqualTo(-1); + // TODO(bachinger): Incorrect masking. Skipped played prerolls not taken into account by masking + assertThat(newPositions.get(0).periodIndex).isEqualTo(1); + assertThat(newPositions.get(0).adGroupIndex).isEqualTo(-1); + } + + @Test + public void play_playedSSAIPreMidPostRolls_skipsAllAds() throws Exception { + ArgumentCaptor oldPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor newPositionArgumentCaptor = + ArgumentCaptor.forClass(PositionInfo.class); + ArgumentCaptor reasonArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + FakeTimeline adTimeline = + FakeTimeline.createMultiPeriodAdTimeline( + "windowId", + /* numberOfPlayedAds= */ Integer.MAX_VALUE, + /* isAdPeriodFlags...= */ true, + false, + true, + true, + false, + true, + true, + true); + Listener listener = mock(Listener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(listener); + AtomicReference sourceReference = new AtomicReference<>(); + sourceReference.set( + new ServerSideAdInsertionMediaSource( + new FakeMediaSource(adTimeline), + contentTimeline -> { + sourceReference + .get() + .setAdPlaybackStates(adTimeline.getAdPlaybackStates(/* windowIndex= */ 0)); + return true; + })); + player.setMediaSource(sourceReference.get()); + player.prepare(); + + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + verify(listener, times(3)) + .onPositionDiscontinuity( + oldPositionArgumentCaptor.capture(), + newPositionArgumentCaptor.capture(), + reasonArgumentCaptor.capture()); + List oldPositions = oldPositionArgumentCaptor.getAllValues(); + List newPositions = newPositionArgumentCaptor.getAllValues(); + List reasons = reasonArgumentCaptor.getAllValues(); + assertThat(reasons).containsExactly(0, 0, 0).inOrder(); + // Auto discontinuity from the empty ad period to the first content period. + assertThat(oldPositions.get(0).periodIndex).isEqualTo(0); + assertThat(oldPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(oldPositions.get(0).positionMs).isEqualTo(0); + assertThat(newPositions.get(0).periodIndex).isEqualTo(1); + assertThat(newPositions.get(0).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(0).positionMs).isEqualTo(0); + // Auto discontinuity from the first content to the second content period. + assertThat(oldPositions.get(1).periodIndex).isEqualTo(1); + assertThat(oldPositions.get(1).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(1).periodIndex).isEqualTo(4); + assertThat(newPositions.get(1).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(1).positionMs).isEqualTo(1250); + // Auto discontinuity from the second content period to the last frame of the last postroll. + assertThat(oldPositions.get(2).periodIndex).isEqualTo(4); + assertThat(oldPositions.get(2).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(2).periodIndex).isEqualTo(7); + assertThat(newPositions.get(2).adGroupIndex).isEqualTo(-1); + assertThat(newPositions.get(2).positionMs).isEqualTo(2500); + } + @Test public void becomingNoisyIgnoredIfBecomingNoisyHandlingIsDisabled() throws Exception { ExoPlayer player = new TestExoPlayerBuilder(context).build(); @@ -8036,7 +8467,7 @@ public final class ExoPlayerTest { /* durationUs = */ 100_000, /* defaultPositionUs = */ 0, /* windowOffsetInFirstPeriodUs= */ 0, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), MediaItem.fromUri("http://foo.bar/fake1")); FakeMediaSource fakeMediaSource1 = new FakeMediaSource(new FakeTimeline(window1)); TimelineWindowDefinition window2 = @@ -8050,7 +8481,7 @@ public final class ExoPlayerTest { /* durationUs = */ 100_000, /* defaultPositionUs = */ 0, /* windowOffsetInFirstPeriodUs= */ 0, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), MediaItem.fromUri("http://foo.bar/fake2")); FakeMediaSource fakeMediaSource2 = new FakeMediaSource(new FakeTimeline(window2)); TimelineWindowDefinition window3 = @@ -8064,7 +8495,7 @@ public final class ExoPlayerTest { /* durationUs = */ 100_000, /* defaultPositionUs = */ 0, /* windowOffsetInFirstPeriodUs= */ 0, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), MediaItem.fromUri("http://foo.bar/fake3")); FakeMediaSource fakeMediaSource3 = new FakeMediaSource(new FakeTimeline(window3)); final Player[] playerHolder = {null}; @@ -8422,7 +8853,7 @@ public final class ExoPlayerTest { /* durationUs= */ 10_000_000, /* defaultPositionUs= */ 0, /* windowOffsetInFirstPeriodUs= */ 0, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), initialMediaItem); TimelineWindowDefinition secondWindow = new TimelineWindowDefinition( @@ -8435,7 +8866,7 @@ public final class ExoPlayerTest { /* durationUs= */ 10_000_000, /* defaultPositionUs= */ 0, /* windowOffsetInFirstPeriodUs= */ 0, - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), initialMediaItem.buildUpon().setTag(1).build()); FakeTimeline timeline = new FakeTimeline(initialWindow); FakeTimeline newTimeline = new FakeTimeline(secondWindow); @@ -9269,7 +9700,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9319,7 +9750,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9365,7 +9796,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9413,7 +9844,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9431,7 +9862,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs + 50_000), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9576,7 +10007,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 20 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9630,7 +10061,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9675,7 +10106,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9693,7 +10124,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9742,7 +10173,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9760,7 +10191,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveConfiguration( @@ -9850,7 +10281,7 @@ public final class ExoPlayerTest { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 8 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(windowStartUnixTimeMs), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), new MediaItem.Builder().setUri(Uri.EMPTY).build())); player.pause(); player.setMediaSource(new FakeMediaSource(liveTimelineWithoutTargetLiveOffset)); 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 ef8b0477f0..403d59567f 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer; +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.mock; import static org.robolectric.Shadows.shadowOf; @@ -22,6 +23,7 @@ import static org.robolectric.Shadows.shadowOf; import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.util.Pair; import androidx.media3.common.AdPlaybackState; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -479,8 +481,7 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ 0, /* requestedContentPositionUs= */ C.TIME_UNSET, /* endPositionUs= */ C.TIME_UNSET, - /* durationUs= */ CONTENT_DURATION_US - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + /* durationUs= */ CONTENT_DURATION_US + DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, /* isFollowedByTransitionToSameStream= */ false, /* isLastInPeriod= */ true, /* isLastInWindow= */ false, @@ -740,6 +741,320 @@ public final class MediaPeriodQueueTest { assertThat(getQueueLength()).isEqualTo(3); } + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_behindAdPositionInSinglePeriodTimeline_resolvesToAd() { + long adPositionUs = DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + 10_000; + AdPlaybackState adPlaybackState = new AdPlaybackState("adsId", adPositionUs); + adPlaybackState = adPlaybackState.withAdDurationsUs(/* adGroupIndex= */ 0, 5_000); + Object windowUid = new Object(); + FakeTimeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ windowUid, + /* isSeekable= */ true, + /* isDynamic= */ false, + TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US, + adPlaybackState)); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, /* periodUid= */ new Pair<>(windowUid, 0), adPositionUs + 1); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowUid, 0)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_toAdPositionInSinglePeriodTimeline_resolvesToAd() { + long adPositionUs = DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + 10_000; + AdPlaybackState adPlaybackState = new AdPlaybackState("adsId", adPositionUs); + adPlaybackState = adPlaybackState.withAdDurationsUs(/* adGroupIndex= */ 0, 5_000); + Object windowUid = new Object(); + FakeTimeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ windowUid, + /* isSeekable= */ true, + /* isDynamic= */ false, + TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US, + adPlaybackState)); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, /* periodUid= */ new Pair<>(windowUid, 0), adPositionUs); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowUid, 0)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_beforeAdPositionInSinglePeriodTimeline_seekNotAdjusted() { + long adPositionUs = DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + 10_000; + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId", adPositionUs).withAdDurationsUs(/* adGroupIndex= */ 0, 5_000); + Object windowUid = new Object(); + FakeTimeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ windowUid, + /* isSeekable= */ true, + /* isDynamic= */ false, + TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US, + adPlaybackState)); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowUid, 0), adPositionUs - 1); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowUid, 0)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(0); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_behindAdInMultiPeriodTimeline_rollForward() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ true, + false, + true, + true, + true, + false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 1), /* positionUs= */ 1); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 0)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + + mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 5), /* positionUs= */ 0); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 2)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_behindAdInMultiPeriodAllAdsPlayed_seekNotAdjusted() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + /* numberOfPlayedAds= */ 4, + /* isAdPeriodFlags...= */ true, + false, + true, + true, + true, + false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 1), /* positionUs= */ 11); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 1)); + + mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 5), /* positionUs= */ 33); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 5)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_behindAdInMultiPeriodFirstTwoAdsPlayed_rollForward() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + /* numberOfPlayedAds= */ 2, + /* isAdPeriodFlags...= */ true, + false, + true, + true, + true, + false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 5), /* positionUs= */ 33); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 3)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_beforeAdInMultiPeriodTimeline_seekNotAdjusted() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, /* numberOfPlayedAds= */ 0, /* isAdPeriodFlags...= */ false, true); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 0), /* positionUs= */ 33); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 0)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_toUnplayedAdInMultiPeriodTimeline_resolvedAsAd() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, /* numberOfPlayedAds= */ 0, /* isAdPeriodFlags...= */ false, true, false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 1), /* positionUs= */ 0); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 1)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_toPlayedAdInMultiPeriodTimeline_skipPlayedAd() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, /* numberOfPlayedAds= */ 1, /* isAdPeriodFlags...= */ false, true, false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 1), /* positionUs= */ 0); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 2)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_toStartOfWindowPlayedAdPreroll_skipsPlayedPrerolls() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, /* numberOfPlayedAds= */ 2, /* isAdPeriodFlags...= */ true, true, false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 0), /* positionUs= */ 0); + + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 2)); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_toPlayedPostrolls_skipsAllButLastPostroll() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + /* numberOfPlayedAds= */ 4, + /* isAdPeriodFlags...= */ false, + true, + true, + true, + true); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 1), /* positionUs= */ 0); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 4)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(-1); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_consecutiveContentPeriods_rollForward() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ true, + false, + false, + false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 3), /* positionUs= */ 10_000); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 0)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(0); + assertThat(mediaPeriodId.adIndexInAdGroup).isEqualTo(0); + assertThat(mediaPeriodId.nextAdGroupIndex).isEqualTo(-1); + } + + @Test + public void + resolveMediaPeriodIdForAdsAfterPeriodPositionChange_onlyConsecutiveContentPeriods_seekNotAdjusted() { + Object windowId = new Object(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ false, + false, + false, + false); + + MediaPeriodId mediaPeriodId = + mediaPeriodQueue.resolveMediaPeriodIdForAdsAfterPeriodPositionChange( + timeline, new Pair<>(windowId, 3), /* positionUs= */ 10_000); + + assertThat(mediaPeriodId.periodUid).isEqualTo(new Pair<>(windowId, 3)); + assertThat(mediaPeriodId.adGroupIndex).isEqualTo(-1); + } + private void setupAdTimeline(long... adGroupTimesUs) { adPlaybackState = new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs) diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java index bf0f957965..c9c90372c2 100644 --- a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java @@ -526,6 +526,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou adPlaybackState, /* fromPositionUs= */ secToUs(cuePoint.getStartTime()), /* contentResumeOffsetUs= */ 0, + // TODO(b/192231683) Use getEndTimeMs()/getStartTimeMs() after jar target was removed /* adDurationsUs...= */ secToUs(cuePoint.getEndTime() - cuePoint.getStartTime())); } return adPlaybackState; diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java index 10623a725c..5304fa637f 100644 --- a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java @@ -438,12 +438,13 @@ import java.util.Set; new AdPlaybackState(checkNotNull(adsId), /* adGroupTimesUs...= */ 0) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdDurationsUs(/* adGroupIndex= */ 0, periodDurationUs) - .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + .withIsServerSideInserted(/* adGroupIndex= */ 0, true) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, adGroup.contentResumeOffsetUs); long periodEndUs = periodStartUs + periodDurationUs; long adDurationsUs = 0; for (int i = 0; i < adGroup.count; i++) { adDurationsUs += adGroup.durationsUs[i]; - if (periodEndUs == adGroup.timeUs + adDurationsUs) { + if (periodEndUs <= adGroup.timeUs + adDurationsUs + 10_000) { // Map the state of the global ad state to the period specific ad state. switch (adGroup.states[i]) { case AdPlaybackState.AD_STATE_PLAYED: diff --git a/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaAdsLoaderTest.java b/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaAdsLoaderTest.java index 97778742ff..cf47d44d4b 100644 --- a/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaAdsLoaderTest.java +++ b/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaAdsLoaderTest.java @@ -1365,7 +1365,9 @@ public final class ImaAdsLoaderTest { } private AdPlaybackState getAdPlaybackState(int periodIndex) { - return timelineWindowDefinitions[periodIndex].adPlaybackState; + int adPlaybackStateCount = timelineWindowDefinitions[periodIndex].adPlaybackStates.size(); + return timelineWindowDefinitions[periodIndex].adPlaybackStates.get( + periodIndex % adPlaybackStateCount); } private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { @@ -1408,7 +1410,11 @@ public final class ImaAdsLoaderTest { adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); TimelineWindowDefinition timelineWindowDefinition = timelineWindowDefinitions[periodIndex]; - assertThat(adPlaybackState.adsId).isEqualTo(timelineWindowDefinition.adPlaybackState.adsId); + assertThat(adPlaybackState.adsId) + .isEqualTo( + timelineWindowDefinition.adPlaybackStates.get( + periodIndex % timelineWindowDefinition.adPlaybackStates.size()) + .adsId); timelineWindowDefinitions[periodIndex] = new TimelineWindowDefinition( timelineWindowDefinition.periodCount, diff --git a/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaUtilTest.java b/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaUtilTest.java index a0435083ce..370e16b77f 100644 --- a/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaUtilTest.java +++ b/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ImaUtilTest.java @@ -458,6 +458,52 @@ public class ImaUtilTest { .isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); } + @Test + public void splitAdPlaybackStateForPeriods_singleAdOfAdGroupSpansMultiplePeriods_correctState() { + int periodCount = 8; + long periodDurationUs = DEFAULT_WINDOW_DURATION_US / periodCount; + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ "adsId", 0, periodDurationUs, 2 * periodDurationUs) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdCount(/* adGroupIndex= */ 1, 1) + .withAdCount(/* adGroupIndex= */ 2, 1) + .withAdDurationsUs( + /* adGroupIndex= */ 0, /* adDurationsUs...= */ + DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + (2 * periodDurationUs)) + .withAdDurationsUs( + /* adGroupIndex= */ 1, /* adDurationsUs...= */ (2 * periodDurationUs)) + .withAdDurationsUs( + /* adGroupIndex= */ 2, /* adDurationsUs...= */ (2 * periodDurationUs)) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withPlayedAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true) + .withIsServerSideInserted(/* adGroupIndex= */ 1, true) + .withIsServerSideInserted(/* adGroupIndex= */ 2, true); + FakeTimeline timeline = + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition( + /* periodCount= */ periodCount, /* id= */ 0L)); + + ImmutableMap adPlaybackStates = + ImaUtil.splitAdPlaybackStateForPeriods(adPlaybackState, timeline); + + assertThat(adPlaybackStates).hasSize(periodCount); + assertThat(adPlaybackStates.get(new Pair<>(0L, 0)).getAdGroup(/* adGroupIndex= */ 0).states[0]) + .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + assertThat(adPlaybackStates.get(new Pair<>(0L, 1)).getAdGroup(/* adGroupIndex= */ 0).states[0]) + .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + assertThat(adPlaybackStates.get(new Pair<>(0L, 2)).adGroupCount).isEqualTo(0); + assertThat(adPlaybackStates.get(new Pair<>(0L, 3)).getAdGroup(/* adGroupIndex= */ 0).states[0]) + .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + assertThat(adPlaybackStates.get(new Pair<>(0L, 4)).getAdGroup(/* adGroupIndex= */ 0).states[0]) + .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + assertThat(adPlaybackStates.get(new Pair<>(0L, 5)).adGroupCount).isEqualTo(0); + assertThat(adPlaybackStates.get(new Pair<>(0L, 6)).getAdGroup(/* adGroupIndex= */ 0).states[0]) + .isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(adPlaybackStates.get(new Pair<>(0L, 7)).getAdGroup(/* adGroupIndex= */ 0).states[0]) + .isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + } + @Test public void splitAdPlaybackStateForPeriods_lateMidrollAdGroupStartTimeUs_adGroupIgnored() { int periodCount = 4; diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSourceFactory.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSourceFactory.java index 0d2bfdca0e..127b9c585d 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSourceFactory.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSourceFactory.java @@ -26,6 +26,7 @@ import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSourceFactory; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; +import com.google.common.collect.ImmutableList; /** Fake {@link MediaSourceFactory} that creates a {@link FakeMediaSource}. */ @UnstableApi @@ -66,7 +67,7 @@ public final class FakeMediaSourceFactory implements MediaSourceFactory { /* durationUs= */ 1000 * C.MICROS_PER_SECOND, /* defaultPositionUs= */ 2 * C.MICROS_PER_SECOND, /* windowOffsetInFirstPeriodUs= */ Util.msToUs(123456789), - AdPlaybackState.NONE, + ImmutableList.of(AdPlaybackState.NONE), mediaItem); return new FakeMediaSource(new FakeTimeline(timelineWindowDefinition)); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java index 270f0cc327..b6ccdfdf3a 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java @@ -15,6 +15,9 @@ */ package androidx.media3.test.utils; +import static androidx.media3.common.util.Util.sum; +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US; +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; import static java.lang.Math.min; import android.net.Uri; @@ -27,7 +30,13 @@ import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** Fake {@link Timeline} which can be setup to return custom {@link TimelineWindowDefinition}s. */ @UnstableApi @@ -52,7 +61,7 @@ public final class FakeTimeline extends Timeline { public final long durationUs; public final long defaultPositionUs; public final long windowOffsetInFirstPeriodUs; - public final AdPlaybackState adPlaybackState; + public final List adPlaybackStates; /** * Creates a window definition that corresponds to a placeholder timeline using the given tag. @@ -179,10 +188,41 @@ public final class FakeTimeline extends Timeline { durationUs, defaultPositionUs, windowOffsetInFirstPeriodUs, - adPlaybackState, + ImmutableList.of(adPlaybackState), FAKE_MEDIA_ITEM.buildUpon().setTag(id).build()); } + /** + * @deprecated Use {@link #TimelineWindowDefinition(int, Object, boolean, boolean, boolean, + * boolean, long, long, long, List, MediaItem)} instead. + */ + @Deprecated + public TimelineWindowDefinition( + int periodCount, + Object id, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + boolean isPlaceholder, + long durationUs, + long defaultPositionUs, + long windowOffsetInFirstPeriodUs, + AdPlaybackState adPlaybackState, + MediaItem mediaItem) { + this( + periodCount, + id, + isSeekable, + isDynamic, + isLive, + isPlaceholder, + durationUs, + defaultPositionUs, + windowOffsetInFirstPeriodUs, + ImmutableList.of(adPlaybackState), + mediaItem); + } + /** * Creates a window definition with ad groups and a custom media item. * @@ -197,7 +237,7 @@ public final class FakeTimeline extends Timeline { * @param defaultPositionUs The default position of the window in microseconds. * @param windowOffsetInFirstPeriodUs The offset of the window in the first period, in * microseconds. - * @param adPlaybackState The ad playback state. + * @param adPlaybackStates The ad playback states for the periods. * @param mediaItem The media item to include in the timeline. */ public TimelineWindowDefinition( @@ -210,7 +250,7 @@ public final class FakeTimeline extends Timeline { long durationUs, long defaultPositionUs, long windowOffsetInFirstPeriodUs, - AdPlaybackState adPlaybackState, + List adPlaybackStates, MediaItem mediaItem) { Assertions.checkArgument(durationUs != C.TIME_UNSET || periodCount == 1); this.periodCount = periodCount; @@ -223,7 +263,7 @@ public final class FakeTimeline extends Timeline { this.durationUs = durationUs; this.defaultPositionUs = defaultPositionUs; this.windowOffsetInFirstPeriodUs = windowOffsetInFirstPeriodUs; - this.adPlaybackState = adPlaybackState; + this.adPlaybackStates = adPlaybackStates; } } @@ -268,6 +308,59 @@ public final class FakeTimeline extends Timeline { return adPlaybackState; } + /** + * Creates a multi-period timeline with ad and content periods specified by the flags passed as + * var-arg arguments. + * + *

Period uid end up being a {@code new Pair<>(windowId, periodIndex)}. + * + * @param windowId The window ID. + * @param numberOfPlayedAds The number of ads that should be marked as played. + * @param isAdPeriodFlags A value of true indicates an ad period. A value of false indicated a + * content period. + * @return A timeline with a single window with as many periods as var-arg arguments. + */ + public static FakeTimeline createMultiPeriodAdTimeline( + Object windowId, int numberOfPlayedAds, boolean... isAdPeriodFlags) { + long periodDurationUs = DEFAULT_WINDOW_DURATION_US / isAdPeriodFlags.length; + AdPlaybackState firstAdPeriodState = + new AdPlaybackState(/* adsId= */ "adsId", /* adGroupTimesUs... */ 0) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdDurationsUs( + /* adGroupIndex= */ 0, DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + periodDurationUs) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + AdPlaybackState commonAdPeriodState = firstAdPeriodState.withAdDurationsUs(0, periodDurationUs); + AdPlaybackState contentPeriodState = new AdPlaybackState(/* adsId= */ "adsId"); + + List adPlaybackStates = new ArrayList<>(); + int playedAdsCounter = 0; + for (boolean isAd : isAdPeriodFlags) { + AdPlaybackState periodAdPlaybackState = + isAd + ? (adPlaybackStates.isEmpty() ? firstAdPeriodState : commonAdPeriodState) + : contentPeriodState; + if (isAd && playedAdsCounter < numberOfPlayedAds) { + periodAdPlaybackState = + periodAdPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + playedAdsCounter++; + } + adPlaybackStates.add(periodAdPlaybackState); + } + return new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition( + isAdPeriodFlags.length, + windowId, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ DEFAULT_WINDOW_DURATION_US, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + /* adPlaybackStates= */ adPlaybackStates, + MediaItem.EMPTY)); + } + /** * Create a fake timeline with one seekable, non-dynamic window with one period and a duration of * {@link TimelineWindowDefinition#DEFAULT_WINDOW_DURATION_US}. @@ -363,6 +456,19 @@ public final class FakeTimeline extends Timeline { @Override public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; + long windowDurationUs = 0; + Period period = new Period(); + for (int i = periodOffsets[windowIndex]; i < periodOffsets[windowIndex + 1]; i++) { + long periodDurationUs = getPeriod(/* periodIndex= */ i, period).durationUs; + if (i == periodOffsets[windowIndex] && periodDurationUs != 0) { + windowDurationUs -= windowDefinition.windowOffsetInFirstPeriodUs; + } + if (periodDurationUs == C.TIME_UNSET) { + windowDurationUs = C.TIME_UNSET; + break; + } + windowDurationUs += periodDurationUs; + } window.set( /* uid= */ windowDefinition.id, windowDefinition.mediaItem, @@ -376,7 +482,7 @@ public final class FakeTimeline extends Timeline { windowDefinition.isDynamic, windowDefinition.isLive ? windowDefinition.mediaItem.liveConfiguration : null, windowDefinition.defaultPositionUs, - windowDefinition.durationUs, + windowDurationUs, periodOffsets[windowIndex], periodOffsets[windowIndex + 1] - 1, windowDefinition.windowOffsetInFirstPeriodUs); @@ -396,11 +502,15 @@ public final class FakeTimeline extends Timeline { TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; Object id = setIds ? windowPeriodIndex : null; Object uid = setIds ? Pair.create(windowDefinition.id, windowPeriodIndex) : null; + AdPlaybackState adPlaybackState = + windowDefinition.adPlaybackStates.get( + periodIndex % windowDefinition.adPlaybackStates.size()); // Arbitrarily set period duration by distributing window duration equally among all periods. long periodDurationUs = - windowDefinition.durationUs == C.TIME_UNSET + periodIndex == windowDefinition.periodCount - 1 + && windowDefinition.durationUs == C.TIME_UNSET ? C.TIME_UNSET - : windowDefinition.durationUs / windowDefinition.periodCount; + : (windowDefinition.durationUs / windowDefinition.periodCount); long positionInWindowUs; if (windowPeriodIndex == 0) { if (windowDefinition.durationUs != C.TIME_UNSET) { @@ -414,9 +524,11 @@ public final class FakeTimeline extends Timeline { id, uid, windowIndex, - periodDurationUs, + periodDurationUs == C.TIME_UNSET + ? C.TIME_UNSET + : periodDurationUs - getServerSideAdInsertionAdDurationUs(adPlaybackState), positionInWindowUs, - windowDefinition.adPlaybackState, + adPlaybackState, windowDefinition.isPlaceholder); return period; } @@ -442,6 +554,22 @@ public final class FakeTimeline extends Timeline { return Pair.create(windowDefinition.id, windowPeriodIndex); } + /** + * Returns a map of ad playback states keyed by the period UID. + * + * @param windowIndex The window index of the window to get the map of ad playback states from. + * @return The map of {@link AdPlaybackState ad playback states}. + */ + public ImmutableMap getAdPlaybackStates(int windowIndex) { + Map adPlaybackStateMap = new HashMap<>(); + TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; + for (int i = 0; i < windowDefinition.adPlaybackStates.size(); i++) { + adPlaybackStateMap.put( + new Pair<>(windowDefinition.id, i), windowDefinition.adPlaybackStates.get(i)); + } + return ImmutableMap.copyOf(adPlaybackStateMap); + } + private static TimelineWindowDefinition[] createDefaultWindowDefinitions(int windowCount) { TimelineWindowDefinition[] windowDefinitions = new TimelineWindowDefinition[windowCount]; for (int i = 0; i < windowCount; i++) { @@ -449,4 +577,15 @@ public final class FakeTimeline extends Timeline { } return windowDefinitions; } + + private static long getServerSideAdInsertionAdDurationUs(AdPlaybackState adPlaybackState) { + long adDurationUs = 0; + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(i); + if (adGroup.isServerSideInserted) { + adDurationUs += sum(adGroup.durationsUs); + } + } + return adDurationUs; + } } diff --git a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeTimelineTest.java b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeTimelineTest.java new file mode 100644 index 0000000000..cebf4ba06c --- /dev/null +++ b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeTimelineTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.test.utils; + +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US; +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.Timeline; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link FakeTimeline}. */ +@RunWith(AndroidJUnit4.class) +public class FakeTimelineTest { + + @Test + public void createMultiPeriodAdTimeline_firstPeriodIsAd() { + Timeline.Window window = new Timeline.Window(); + Timeline.Period period = new Timeline.Period(); + Object windowId = new Object(); + int numberOfPlayedAds = 2; + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + windowId, + numberOfPlayedAds, + /* isAdPeriodFlags...= */ true, + false, + true, + true, + true, + false, + true); + + assertThat(timeline.getWindowCount()).isEqualTo(1); + assertThat(timeline.getPeriodCount()).isEqualTo(7); + // Assert content periods and window duration. + Timeline.Period contentPeriod1 = timeline.getPeriod(/* periodIndex= */ 1, period); + Timeline.Period contentPeriod5 = timeline.getPeriod(/* periodIndex= */ 5, period); + assertThat(contentPeriod1.durationUs).isEqualTo(DEFAULT_WINDOW_DURATION_US / 7); + assertThat(contentPeriod5.durationUs).isEqualTo(DEFAULT_WINDOW_DURATION_US / 7); + assertThat(contentPeriod1.getAdGroupCount()).isEqualTo(0); + assertThat(contentPeriod5.getAdGroupCount()).isEqualTo(0); + timeline.getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(windowId); + assertThat(window.durationUs).isEqualTo(contentPeriod1.durationUs + contentPeriod5.durationUs); + assertThat(window.positionInFirstPeriodUs).isEqualTo(DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US); + // Assert ad periods. + int[] adIndices = {0, 2, 3, 4, 6}; + int adCounter = 0; + for (int periodIndex : adIndices) { + Timeline.Period adPeriod = timeline.getPeriod(periodIndex, period); + assertThat(adPeriod.isServerSideInsertedAdGroup(0)).isTrue(); + assertThat(adPeriod.getAdGroupCount()).isEqualTo(1); + assertThat(adPeriod.durationUs).isEqualTo(0); + if (adPeriod.getAdGroupCount() > 0) { + if (adCounter < numberOfPlayedAds) { + assertThat(adPeriod.getAdState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)) + .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + } else { + assertThat(adPeriod.getAdState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)) + .isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + } + adCounter++; + } + long expectedDurationUs = + (DEFAULT_WINDOW_DURATION_US / 7) + + (periodIndex == 0 ? DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US : 0); + assertThat(adPeriod.getAdDurationUs(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)) + .isEqualTo(expectedDurationUs); + } + } + + @Test + public void createMultiPeriodAdTimeline_firstPeriodIsContent_correctWindowDurationUs() { + Timeline.Window window = new Timeline.Window(); + FakeTimeline timeline = + FakeTimeline.createMultiPeriodAdTimeline( + /* windowId= */ new Object(), + /* numberOfPlayedAds= */ 0, + /* isAdPeriodFlags...= */ false, + true, + true, + false); + + timeline.getWindow(/* windowIndex= */ 0, window); + // Assert content periods and window duration. + assertThat(window.durationUs).isEqualTo(DEFAULT_WINDOW_DURATION_US / 2); + assertThat(window.positionInFirstPeriodUs).isEqualTo(DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US); + } +}