diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 09688fa73a..ac7b5ce749 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -399,7 +399,7 @@ "uri": "ssai://dai.google.com/?contentSourceId=2528370&videoId=tears-of-steel&format=2&adsId=1" }, { - "name": "HLS Live: Big Buck Bunny (mid), 3 ads each [10 s]", + "name": "HLS Live: Big Buck Bunny (mid), 3 ads [10/10/10s]", "uri": "ssai://dai.google.com/?assetKey=sN_IYUG8STe1ZzhIIE_ksA&format=2&adsId=3" }, { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java index c0a1828be6..3a4a1f4182 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java @@ -161,16 +161,25 @@ public final class ServerSideAdInsertionMediaSource extends BaseMediaSource checkArgument(Util.areEqual(adsId, adPlaybackState.adsId)); @Nullable AdPlaybackState oldAdPlaybackState = this.adPlaybackStates.get(periodUid); if (oldAdPlaybackState != null) { - for (int i = adPlaybackState.removedAdGroupCount; i < adPlaybackState.adGroupCount; i++) { - AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(i); + for (int adGroupIndex = adPlaybackState.removedAdGroupCount; + adGroupIndex < adPlaybackState.adGroupCount; + adGroupIndex++) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); checkArgument(adGroup.isServerSideInserted); - if (i < oldAdPlaybackState.adGroupCount) { - checkArgument( - getAdCountInGroup(adPlaybackState, /* adGroupIndex= */ i) - >= getAdCountInGroup(oldAdPlaybackState, /* adGroupIndex= */ i)); + if (adGroupIndex < oldAdPlaybackState.adGroupCount + && getAdCountInGroup(adPlaybackState, /* adGroupIndex= */ adGroupIndex) + < getAdCountInGroup(oldAdPlaybackState, /* adGroupIndex= */ adGroupIndex)) { + // Removing ads from an ad group is only allowed when the group has been split. + AdPlaybackState.AdGroup nextAdGroup = adPlaybackState.getAdGroup(adGroupIndex + 1); + long sumOfSplitContentResumeOffsetUs = + adGroup.contentResumeOffsetUs + nextAdGroup.contentResumeOffsetUs; + AdPlaybackState.AdGroup oldAdGroup = oldAdPlaybackState.getAdGroup(adGroupIndex); + checkArgument(sumOfSplitContentResumeOffsetUs == oldAdGroup.contentResumeOffsetUs); + checkArgument(adGroup.timeUs + adGroup.contentResumeOffsetUs == nextAdGroup.timeUs); } if (adGroup.timeUs == C.TIME_END_OF_SOURCE) { - checkArgument(getAdCountInGroup(adPlaybackState, /* adGroupIndex= */ i) == 0); + checkArgument( + getAdCountInGroup(adPlaybackState, /* adGroupIndex= */ adGroupIndex) == 0); } } } 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 1f410a2e36..9929003b58 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 @@ -15,20 +15,21 @@ */ package androidx.media3.exoplayer.ima; +import static androidx.media3.common.AdPlaybackState.AD_STATE_AVAILABLE; +import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.msToUs; -import static androidx.media3.common.util.Util.sum; import static androidx.media3.common.util.Util.usToMs; +import static androidx.media3.exoplayer.ima.ImaUtil.addLiveAdBreak; import static androidx.media3.exoplayer.ima.ImaUtil.expandAdGroupPlaceholder; import static androidx.media3.exoplayer.ima.ImaUtil.getAdGroupAndIndexInMultiPeriodWindow; import static androidx.media3.exoplayer.ima.ImaUtil.secToMsRounded; import static androidx.media3.exoplayer.ima.ImaUtil.secToUsRounded; +import static androidx.media3.exoplayer.ima.ImaUtil.splitAdGroup; import static androidx.media3.exoplayer.ima.ImaUtil.splitAdPlaybackStateForPeriods; -import static androidx.media3.exoplayer.ima.ImaUtil.updateAdDurationAndPropagate; import static androidx.media3.exoplayer.ima.ImaUtil.updateAdDurationInAdGroup; import static androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; -import static java.lang.Math.min; import static java.lang.annotation.ElementType.TYPE_USE; import android.content.Context; @@ -52,6 +53,7 @@ import androidx.media3.common.Player; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.ConditionVariable; +import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.datasource.TransferListener; @@ -451,6 +453,8 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou } } + private static final String TAG = "ImaSSAIMediaSource"; + private final MediaItem mediaItem; private final Player player; private final MediaSource.Factory contentMediaSourceFactory; @@ -472,7 +476,6 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Nullable private IOException loadError; private @MonotonicNonNull Timeline contentTimeline; private AdPlaybackState adPlaybackState; - private int firstSeenAdIndexInAdGroup; private ImaServerSideAdInsertionMediaSource( MediaItem mediaItem, @@ -720,46 +723,6 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou return adPlaybackState; } - private AdPlaybackState addLiveAdBreak( - Ad ad, long currentPeriodPositionUs, AdPlaybackState adPlaybackState) { - AdPodInfo adPodInfo = ad.getAdPodInfo(); - long adDurationUs = secToUsRounded(ad.getDuration()); - int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; - // TODO(b/208398934) Support seeking backwards. - if (adIndexInAdGroup == 0 || adPlaybackState.adGroupCount == 1) { - firstSeenAdIndexInAdGroup = adIndexInAdGroup; - // Adjust count and ad index in case we joined the live stream within an ad group. - int adCount = adPodInfo.getTotalAds() - firstSeenAdIndexInAdGroup; - adIndexInAdGroup -= firstSeenAdIndexInAdGroup; - // First ad of group. Create a new group with all ads. - long[] adDurationsUs = - updateAdDurationAndPropagate( - new long[adCount], - adIndexInAdGroup, - adDurationUs, - msToUs(secToMsRounded(adPodInfo.getMaxDuration()))); - adPlaybackState = - addAdGroupToAdPlaybackState( - adPlaybackState, - /* fromPositionUs= */ currentPeriodPositionUs, - /* contentResumeOffsetUs= */ sum(adDurationsUs), - /* adDurationsUs...= */ adDurationsUs); - } else { - int adGroupIndex = adPlaybackState.adGroupCount - 2; - adIndexInAdGroup -= firstSeenAdIndexInAdGroup; - if (adPodInfo.getTotalAds() == adPodInfo.getAdPosition()) { - // Reset the ad index whe we are at the last ad in the group. - firstSeenAdIndexInAdGroup = 0; - } - adPlaybackState = - updateAdDurationInAdGroup(adGroupIndex, adIndexInAdGroup, adDurationUs, adPlaybackState); - AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); - return adPlaybackState.withContentResumeOffsetUs( - adGroupIndex, min(adGroup.contentResumeOffsetUs, sum(adGroup.durationsUs))); - } - return adPlaybackState; - } - private static AdPlaybackState skipAd(Ad ad, AdPlaybackState adPlaybackState) { AdPodInfo adPodInfo = ad.getAdPodInfo(); int adGroupIndex = adPodInfo.getPodIndex(); @@ -815,11 +778,27 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou adGroupIndex = adGroupIndexAndAdIndexInAdGroup.first; adIndexInAdGroup = adGroupIndexAndAdIndexInAdGroup.second; } - int adState = adPlaybackState.getAdGroup(adGroupIndex).states[adIndexInAdGroup]; - if (adState == AdPlaybackState.AD_STATE_AVAILABLE - || adState == AdPlaybackState.AD_STATE_UNAVAILABLE) { - setAdPlaybackState( - adPlaybackState.withPlayedAd(adGroupIndex, /* adIndexInAdGroup= */ adIndexInAdGroup)); + + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + int adState = adGroup.states[adIndexInAdGroup]; + if (adState == AD_STATE_AVAILABLE || adState == AD_STATE_UNAVAILABLE) { + AdPlaybackState newAdPlaybackState = + adPlaybackState.withPlayedAd(adGroupIndex, /* adIndexInAdGroup= */ adIndexInAdGroup); + adGroup = newAdPlaybackState.getAdGroup(adGroupIndex); + if (isLiveStream + && newPosition.adGroupIndex == C.INDEX_UNSET + && adIndexInAdGroup < adGroup.states.length - 1 + && adGroup.states[adIndexInAdGroup + 1] == AD_STATE_AVAILABLE) { + // There is an available ad after the ad period that just ended being played! + Log.w(TAG, "Detected late ad event. Regrouping trailing ads into separate ad group."); + newAdPlaybackState = + splitAdGroup( + adGroup, + adGroupIndex, + /* splitIndexExclusive= */ adIndexInAdGroup + 1, + newAdPlaybackState); + } + setAdPlaybackState(newAdPlaybackState); } } } @@ -887,12 +866,18 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou long positionInWindowUs = timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period()) .positionInWindowUs; - long currentPeriodPosition = msToUs(player.getContentPosition()) - positionInWindowUs; + long currentContentPeriodPositionUs = + msToUs(player.getContentPosition()) - positionInWindowUs; + Ad ad = event.getAd(); + AdPodInfo adPodInfo = ad.getAdPodInfo(); newAdPlaybackState = addLiveAdBreak( - event.getAd(), - currentPeriodPosition, - newAdPlaybackState.equals(AdPlaybackState.NONE) + currentContentPeriodPositionUs, + /* adDurationUs= */ secToUsRounded(ad.getDuration()), + /* adPositionInAdPod= */ adPodInfo.getAdPosition(), + /* totalAdDurationUs= */ secToUsRounded(adPodInfo.getMaxDuration()), + /* totalAdsInAdPod= */ adPodInfo.getTotalAds(), + /* adPlaybackState= */ newAdPlaybackState.equals(AdPlaybackState.NONE) ? new AdPlaybackState(adsId) : newAdPlaybackState); } else { 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 5619c7f13e..9c24e62009 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 @@ -15,10 +15,14 @@ */ package androidx.media3.exoplayer.ima; +import static androidx.media3.common.AdPlaybackState.AD_STATE_AVAILABLE; +import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.sum; +import static androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; +import static androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil.getMediaPeriodPositionUsForContent; import static java.lang.Math.max; import android.content.Context; @@ -30,8 +34,10 @@ import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import androidx.media3.common.AdOverlayInfo; import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.AdPlaybackState.AdGroup; import androidx.media3.common.AdViewProvider; import androidx.media3.common.C; +import androidx.media3.common.Player; import androidx.media3.common.Timeline; import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSchemeDataSource; @@ -322,7 +328,7 @@ import java.util.Set; @CheckResult public static AdPlaybackState updateAdDurationInAdGroup( int adGroupIndex, int adIndexInAdGroup, long adDurationUs, AdPlaybackState adPlaybackState) { - AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); checkArgument(adIndexInAdGroup < adGroup.durationsUs.length); long[] adDurationsUs = updateAdDurationAndPropagate( @@ -334,25 +340,28 @@ import java.util.Set; } /** - * Updates the duration of the given ad in the array and propagates the difference to the total - * duration to the next ad. If the updated ad is the last ad, the remaining duration is wrapped - * around to the first ad in the group. + * Updates the duration of the given ad in the array. + * + *

The remaining difference when subtracting {@code adDurationUs} from {@code + * remainingDurationUs} is used as the duration of the next ad after {@code adIndex}. If the + * updated ad is the last ad, the remaining duration is wrapped around to the first ad of the + * group. * *

The remaining ad duration is only propagated if the destination ad has a duration of 0. * * @param adDurationsUs The array to edit. * @param adIndex The index of the ad in the durations array. * @param adDurationUs The new ad duration. - * @param totalDurationUs The total duration the difference of which to propagate to the next ad. + * @param remainingDurationUs The remaining ad duration before updating the new ad duration. * @return The updated input array, for convenience. */ - /* package */ static long[] updateAdDurationAndPropagate( - long[] adDurationsUs, int adIndex, long adDurationUs, long totalDurationUs) { + private static long[] updateAdDurationAndPropagate( + long[] adDurationsUs, int adIndex, long adDurationUs, long remainingDurationUs) { adDurationsUs[adIndex] = adDurationUs; int nextAdIndex = (adIndex + 1) % adDurationsUs.length; if (adDurationsUs[nextAdIndex] == 0) { // Propagate the remaining duration to the next ad. - adDurationsUs[nextAdIndex] = max(0, totalDurationUs - adDurationUs); + adDurationsUs[nextAdIndex] = max(0, remainingDurationUs - adDurationUs); } return adDurationsUs; } @@ -389,7 +398,7 @@ import java.util.Set; AdPlaybackState contentOnlyAdPlaybackState = new AdPlaybackState(adsId); Map adPlaybackStates = new HashMap<>(); for (int i = adPlaybackState.removedAdGroupCount; i < adPlaybackState.adGroupCount; i++) { - AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); + AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); if (adGroup.timeUs == C.TIME_END_OF_SOURCE) { checkState(i == adPlaybackState.adGroupCount - 1); // The last ad group is a placeholder for a potential post roll. We can just stop here. @@ -432,7 +441,7 @@ import java.util.Set; } private static AdPlaybackState splitAdGroupForPeriod( - Object adsId, AdPlaybackState.AdGroup adGroup, long periodStartUs, long periodDurationUs) { + Object adsId, AdGroup adGroup, long periodStartUs, long periodDurationUs) { AdPlaybackState adPlaybackState = new AdPlaybackState(checkNotNull(adsId), /* adGroupTimesUs...= */ 0) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -484,7 +493,7 @@ import java.util.Set; long totalElapsedContentDurationUs = 0; for (int i = adPlaybackState.removedAdGroupCount; i < adPlaybackState.adGroupCount; i++) { int adIndexInAdGroup = 0; - AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); + AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); long adGroupDurationUs = sum(adGroup.durationsUs); long elapsedAdGroupAdDurationUs = 0; for (int j = periodIndex; j < contentTimeline.getPeriodCount(); j++) { @@ -513,6 +522,181 @@ import java.util.Set; throw new IllegalStateException(); } + /** + * Called when the SDK emits a {@code LOADED} event of an IMA SSAI live stream. + * + *

For each ad, the SDK emits a {@code LOADED} event at the start of the ad. The {@code LOADED} + * event provides the information of a certain ad (index and duration) and its ad pod (number of + * ads and total ad duration) that is mapped to an ad in an {@linkplain AdGroup ad group} of an + * {@linkplain AdPlaybackState ad playback state} to reflect ads in the ExoPlayer media structure. + * + *

In the normal case (when all ad information is available completely and in time), the + * life-cycle of a live ad group and its ads has these phases: + * + *

    + *
  1. When playing content and a {@code LOADED} event arrives, an ad group is inserted at the + * current position with the number of ads reported by the ad pod. The duration of the first + * ad is set and its state is set to {@link AdPlaybackState#AD_STATE_AVAILABLE}. The + * duration of the 2nd ad is set to the remaining duration of the total ad group duration. + * This pads out the duration of the ad group, so it doesn't end before the next ad event + * arrives. When inserting the ad group at the current position, the player immediately + * advances to play the inserted ad period. + *
  2. When playing an ad group and a further {@code LOADED} event arrives, the ad state is + * inspected to find the {@linkplain AdPlaybackState#getAdGroupIndexForPositionUs(long, + * long) ad group currently being played}. We query for the first {@linkplain + * AdPlaybackState#AD_STATE_UNAVAILABLE unavailable ad} of that ad group, override its + * placeholder duration, mark it {@linkplain AdPlaybackState#AD_STATE_AVAILABLE available} + * and propagate the remainder of the placeholder duration to the next ad. Repeating this + * step until all ads are configured and marked as available. + *
  3. When playing an ad and a {@code LOADED} event arrives but no more ads are in {@link + * AdPlaybackState#AD_STATE_UNAVAILABLE}, the group is expanded by inserting a new ad at the + * end of the ad group. + *
  4. After playing an ad: When playback exits from an ad period to the next ad or back to + * content, {@link ImaServerSideAdInsertionMediaSource} detects {@linkplain + * Player.Listener#onPositionDiscontinuity(Player.PositionInfo, Player.PositionInfo, int) a + * position discontinuity}, identifies {@linkplain Player.PositionInfo#adIndexInAdGroup the + * ad being exited} and {@linkplain AdPlaybackState#AD_STATE_PLAYED marks the ad as played}. + *
+ * + *

Some edge-cases need consideration. When a user joins a live stream during an ad being + * played, ad information previous to the first received {@code LOADED} event is missing. Only ads + * starting from the first ad with full information are inserted into the group (back to happy + * path step 2). + * + *

There is further a chance, that a (pre-fetch) event arrives after the ad group has already + * ended. In such a case, the pre-fetch ad starts a new ad group with the remaining ads in the + * same way as the during-ad-joiner case that can afterwards be expanded again (back to end of + * happy path step 2). + * + * @param currentContentPeriodPositionUs The current public content position, in microseconds. + * @param adDurationUs The duration of the ad to be inserted, in microseconds. + * @param adPositionInAdPod The ad position in the ad pod (Note: starts with index 1). + * @param totalAdDurationUs The total duration of all ads as declared by the ad pod. + * @param totalAdsInAdPod The total number of ads declared by the ad pod. + * @param adPlaybackState The ad playback state with the current ad information. + * @return The updated {@link AdPlaybackState}. + */ + @CheckResult + public static AdPlaybackState addLiveAdBreak( + long currentContentPeriodPositionUs, + long adDurationUs, + int adPositionInAdPod, + long totalAdDurationUs, + int totalAdsInAdPod, + AdPlaybackState adPlaybackState) { + checkArgument(adPositionInAdPod > 0); + long mediaPeriodPositionUs = + getMediaPeriodPositionUsForContent( + currentContentPeriodPositionUs, /* nextAdGroupIndex= */ C.INDEX_UNSET, adPlaybackState); + // TODO(b/217187518) Support seeking backwards. + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + mediaPeriodPositionUs, /* periodDurationUs= */ C.TIME_UNSET); + if (adGroupIndex == C.INDEX_UNSET) { + int adIndexInAdGroup = adPositionInAdPod - 1; + long[] adDurationsUs = + updateAdDurationAndPropagate( + new long[totalAdsInAdPod - adIndexInAdGroup], + /* adIndex= */ 0, + adDurationUs, + totalAdDurationUs); + adPlaybackState = + addAdGroupToAdPlaybackState( + adPlaybackState, + /* fromPositionUs= */ currentContentPeriodPositionUs, + /* contentResumeOffsetUs= */ sum(adDurationsUs), + /* adDurationsUs...= */ adDurationsUs); + adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + mediaPeriodPositionUs, /* periodDurationUs= */ C.TIME_UNSET); + if (adGroupIndex != C.INDEX_UNSET) { + adPlaybackState = + adPlaybackState + .withAvailableAd(adGroupIndex, /* adIndexInAdGroup= */ 0) + .withOriginalAdCount(adGroupIndex, /* originalAdCount= */ totalAdsInAdPod); + } + } else { + AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + long[] newDurationsUs = Arrays.copyOf(adGroup.durationsUs, adGroup.count); + int nextUnavailableAdIndex = getNextUnavailableAdIndex(adGroup); + if (adGroup.originalCount < totalAdsInAdPod || nextUnavailableAdIndex == adGroup.count) { + int adInAdGroupCount = max(totalAdsInAdPod, nextUnavailableAdIndex + 1); + adPlaybackState = + adPlaybackState + .withAdCount(adGroupIndex, adInAdGroupCount) + .withOriginalAdCount(adGroupIndex, /* originalAdCount= */ adInAdGroupCount); + newDurationsUs = Arrays.copyOf(newDurationsUs, adInAdGroupCount); + newDurationsUs[nextUnavailableAdIndex] = totalAdDurationUs; + Arrays.fill( + newDurationsUs, + /* fromIndex= */ nextUnavailableAdIndex + 1, + /* toIndex= */ adInAdGroupCount, + /* val= */ 0L); + } + long remainingDurationUs = max(adDurationUs, newDurationsUs[nextUnavailableAdIndex]); + updateAdDurationAndPropagate( + newDurationsUs, nextUnavailableAdIndex, adDurationUs, remainingDurationUs); + adPlaybackState = + adPlaybackState + .withAdDurationsUs(adGroupIndex, newDurationsUs) + .withAvailableAd(adGroupIndex, nextUnavailableAdIndex) + .withContentResumeOffsetUs(adGroupIndex, sum(newDurationsUs)); + } + return adPlaybackState; + } + + /** + * Splits the ad group at an available ad at a given split index. + * + *

When splitting, the ads from and after the split index are removed from the existing ad + * group. Then the ad events of all removed available ads are replicated to get the exact same + * result as if the new ad group was created by SDK ad events. + * + * @param adGroup The ad group to split. + * @param adGroupIndex The index of the ad group in the ad playback state. + * @param splitIndexExclusive The first index that should be part of the newly created ad group. + * @param adPlaybackState The ad playback state to modify. + * @return The ad playback state with the split ad group. + */ + @CheckResult + public static AdPlaybackState splitAdGroup( + AdGroup adGroup, int adGroupIndex, int splitIndexExclusive, AdPlaybackState adPlaybackState) { + checkArgument(splitIndexExclusive > 0 && splitIndexExclusive < adGroup.count); + // Remove the ads from the ad group. + for (int i = 0; i < adGroup.count - splitIndexExclusive; i++) { + adPlaybackState = adPlaybackState.withLastAdRemoved(adGroupIndex); + } + AdGroup previousAdGroup = adPlaybackState.getAdGroup(adGroupIndex); + long newAdGroupTimeUs = previousAdGroup.timeUs + previousAdGroup.contentResumeOffsetUs; + // Replicate ad events for each available ad that has been removed. + @AdPlaybackState.AdState + int[] removedStates = Arrays.copyOfRange(adGroup.states, splitIndexExclusive, adGroup.count); + long[] removedDurationsUs = + Arrays.copyOfRange(adGroup.durationsUs, splitIndexExclusive, adGroup.count); + long remainingAdDurationUs = sum(removedDurationsUs); + for (int i = 0; i < removedStates.length && removedStates[i] == AD_STATE_AVAILABLE; i++) { + adPlaybackState = + addLiveAdBreak( + newAdGroupTimeUs, + /* adDurationUs= */ removedDurationsUs[i], + /* adPositionInAdPod= */ i + 1, + /* totalAdDurationUs= */ remainingAdDurationUs, + /* totalAdsInAdPod= */ removedDurationsUs.length, + adPlaybackState); + remainingAdDurationUs -= removedDurationsUs[i]; + } + return adPlaybackState; + } + + private static int getNextUnavailableAdIndex(AdGroup adGroup) { + for (int i = 0; i < adGroup.states.length; i++) { + if (adGroup.states[i] == AD_STATE_UNAVAILABLE) { + return i; + } + } + return adGroup.states.length; + } + /** * Converts a time in seconds to the corresponding time in microseconds. * 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 370e16b77f..8dfeb785ba 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 @@ -15,7 +15,14 @@ */ package androidx.media3.exoplayer.ima; +import static androidx.media3.common.AdPlaybackState.AD_STATE_AVAILABLE; +import static androidx.media3.common.AdPlaybackState.AD_STATE_ERROR; +import static androidx.media3.common.AdPlaybackState.AD_STATE_PLAYED; +import static androidx.media3.common.AdPlaybackState.AD_STATE_SKIPPED; +import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE; +import static androidx.media3.exoplayer.ima.ImaUtil.addLiveAdBreak; import static androidx.media3.exoplayer.ima.ImaUtil.getAdGroupAndIndexInMultiPeriodWindow; +import static androidx.media3.exoplayer.ima.ImaUtil.splitAdGroup; 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; @@ -449,13 +456,13 @@ public class ImaUtilTest { ImaUtil.splitAdPlaybackStateForPeriods(adPlaybackState, timeline); assertThat(adPlaybackStates.get(new Pair<>(0L, 0)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + .isEqualTo(AD_STATE_PLAYED); assertThat(adPlaybackStates.get(new Pair<>(0L, 1)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_SKIPPED); + .isEqualTo(AD_STATE_SKIPPED); assertThat(adPlaybackStates.get(new Pair<>(0L, 2)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_ERROR); + .isEqualTo(AD_STATE_ERROR); assertThat(adPlaybackStates.get(new Pair<>(0L, 3)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + .isEqualTo(AD_STATE_UNAVAILABLE); } @Test @@ -489,19 +496,19 @@ public class ImaUtilTest { assertThat(adPlaybackStates).hasSize(periodCount); assertThat(adPlaybackStates.get(new Pair<>(0L, 0)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + .isEqualTo(AD_STATE_PLAYED); assertThat(adPlaybackStates.get(new Pair<>(0L, 1)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + .isEqualTo(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); + .isEqualTo(AD_STATE_PLAYED); assertThat(adPlaybackStates.get(new Pair<>(0L, 4)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_PLAYED); + .isEqualTo(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); + .isEqualTo(AD_STATE_UNAVAILABLE); assertThat(adPlaybackStates.get(new Pair<>(0L, 7)).getAdGroup(/* adGroupIndex= */ 0).states[0]) - .isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + .isEqualTo(AD_STATE_UNAVAILABLE); } @Test @@ -843,4 +850,530 @@ public class ImaUtilTest { assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(2); assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(1); } + + @Test + public void addLiveAdBreak_threeAdsHappyPath_createsNewAdGroupAndPropagates() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + + // Initial LOADED event while playing in content, makes the player advancing to the first ad + // period: [/* adGroupIndex= */ 0, /* adIndexInAdGroup */ 0, /* nextAdGroupIndex= */ -1]. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L, + /* adDurationUs= */ 10_000_001L, + /* adPositionInAdPod= */ 1, + /* totalAdDurationUs= */ 30_000_001L, + /* totalAdsInAdPod= */ 3, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000L); + assertThat(adPlaybackState.getAdGroup(0).isServerSideInserted).isTrue(); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 20_000_000L, 0L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(30_000_001); + + // Second load event while first ad is playing. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_001L, + /* adDurationUs= */ 10_000_010L, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 30_000_011L, + /* totalAdsInAdPod= */ 3, + adPlaybackState); + // Player advances to the second ad period: + // [/* adGroupIndex= */ 0, /* adIndexInAdGroup */ 1, /* nextAdGroupIndex= */ -1]. + // The first ad period is marked as played. + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_PLAYED, AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 10_000_010L, 9_999_990L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(30_000_001L); + + // Player advances to the third ad period: + // [/* adGroupIndex= */ 0, /* adIndexInAdGroup */ 2, /* nextAdGroupIndex= */ -1]. + // The 2nd ad period is marked as played. + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1); + // Third LOADED event while already playing on the last ad period. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_001L + 10_000_010L, + /* adDurationUs= */ 10_000_100L, + /* adPositionInAdPod= */ 3, + /* totalAdDurationUs= */ 30_000_111L, + /* totalAdsInAdPod= */ 3, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_PLAYED, AD_STATE_PLAYED, AD_STATE_AVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 10_000_010L, 10_000_100L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(30_000_111L); + + // Additional pre-fetch LOADED event with no remaining unavailable ad slot increases ad count. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000 + + 10_000_001L + + 10_000_010L + + 10_000_100L, + /* adDurationUs= */ 10_001_000L, + /* adPositionInAdPod= */ 4, + /* totalAdDurationUs= */ 29_001_111L, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + // Player advances to the content period: + // [/* adGroupIndex= */ -1, /* adIndexInAdGroup */ -1, /* nextAdGroupIndex= */ 1]. + // The 3rd ad period is marked as played. + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_PLAYED, AD_STATE_PLAYED, AD_STATE_PLAYED, AD_STATE_AVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 10_000_010L, 10_000_100L, 10_001_000L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(40_001_111L); + } + + @Test + public void addLiveAdBreak_groupExpandsFromTwoAdsToFourAds_createsNewAdGroupAndExpands() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + + // Initial LOADED event while playing in content. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L, + /* adDurationUs= */ 10_000_001L, + /* adPositionInAdPod= */ 1, + /* totalAdDurationUs= */ 19_000_011L, + /* totalAdsInAdPod= */ 2, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000L); + assertThat(adPlaybackState.getAdGroup(0).isServerSideInserted).isTrue(); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 9_000_010L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(19_000_011); + + // Second LOADED event: switch to a ad pod with 4 ads + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_001L, + /* adDurationUs= */ 10_000_010L, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 40_000_011L, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000L); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly( + AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 10_000_010L, 30_000_001L, 0L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(50_000_012L); + + // Third LOADED event + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_001L + 10_000_010L, + /* adDurationUs= */ 10_000_100L, + /* adPositionInAdPod= */ 3, + /* totalAdDurationUs= */ 40_000_111L, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly( + AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 10_000_010L, 10_000_100L, 19999901L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(50_000_012L); + + // Last LOADED event + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + + 10_000_001L + + 10_000_010L + + 10_000_100L, + /* adDurationUs= */ 10_001_000L, + /* adPositionInAdPod= */ 4, + /* totalAdDurationUs= */ 40_001_111L, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly( + AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_AVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_001L, 10_000_010L, 10_000_100L, 10_001_000L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(40_001_111); + } + + @Test + public void addLiveAdBreak_groupExpandsFromOneToTwoAdsAfterAdGroupCompletion_createsNewAdGroup() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + + // Initial LOADED event while playing in content. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L, + /* adDurationUs= */ 10_000_001L, + /* adPositionInAdPod= */ 1, + /* totalAdDurationUs= */ 10_000_001L, + /* totalAdsInAdPod= */ 1, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000L); + assertThat(adPlaybackState.getAdGroup(0).states).asList().containsExactly(AD_STATE_AVAILABLE); + assertThat(adPlaybackState.getAdGroup(0).durationsUs).asList().containsExactly(10_000_001L); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(10_000_001L); + + // Player advances to the content period: + // [/* adGroupIndex= */ -1, /* adIndexInAdGroup */ -1, /* nextAdGroupIndex= */ 1] + // The ad group is completely played. + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + // A 'late LOADED event' at the end of the completed ad group. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_001L, + /* adDurationUs= */ 10_000_010L, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 20_000_011L, + /* totalAdsInAdPod= */ 2, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(1).count).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(1).originalCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(1).timeUs).isEqualTo(123_000_000L + 10_000_001L); + assertThat(adPlaybackState.getAdGroup(1).states).asList().containsExactly(AD_STATE_AVAILABLE); + assertThat(adPlaybackState.getAdGroup(1).durationsUs).asList().containsExactly(10_000_010L); + assertThat(adPlaybackState.getAdGroup(1).contentResumeOffsetUs).isEqualTo(10_000_010L); + } + + @Test + public void addLiveAdBreak_joinInSecondAd_createsNewAdGroupAndExpands() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + + // First LOADED event arrives with position 2 like when joining during an ad. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 30_000_000, + /* totalAdsInAdPod= */ 3, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000L); + assertThat(adPlaybackState.getAdGroup(0).isServerSideInserted).isTrue(); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_000L, 20_000_000L) // Placeholder duration. + .inOrder(); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(30_000_000L); + + // Second LOADED event overrides placeholder duration. + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_000L, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 3, + /* totalAdDurationUs= */ 30_000_000, + /* totalAdsInAdPod= */ 3, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_AVAILABLE, AD_STATE_AVAILABLE); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_000L, 10_000_000L); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(20_000_000L); + + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1); + // Delayed pre-fetch LOADED event in content (creates new ad group). + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000L + 10_000_000L + 10_000_000L, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 4, + /* totalAdDurationUs= */ 30_000_000, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(1).count).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(1).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(1).states).asList().containsExactly(AD_STATE_AVAILABLE); + assertThat(adPlaybackState.getAdGroup(1).durationsUs).asList().containsExactly(10_000_000L); + assertThat(adPlaybackState.getAdGroup(1).contentResumeOffsetUs).isEqualTo(10_000_000L); + } + + @Test + public void splitAdGroup_singleTrailingAdInCompletedGroup_correctlySplit() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 1, + /* totalAdDurationUs= */ 10_000_000, + /* totalAdsInAdPod= */ 2, + adPlaybackState); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000 + 10_000_000, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 10_000_000, + /* totalAdsInAdPod= */ 2, + adPlaybackState); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000 + 10_000_000 + 10_000_000, + /* adDurationUs= */ 15_000_000, + /* adPositionInAdPod= */ 3, + /* totalAdDurationUs= */ 45_000_000, + /* totalAdsInAdPod= */ 3, + adPlaybackState); + adPlaybackState = + adPlaybackState + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ 0); + + // Split the current adGroup at ad index 2: + // [AD_STATE_PLAYED, AD_STATE_PLAYED, AD_STATE_AVAILABLE] + adPlaybackState = + splitAdGroup(adGroup, /* adGroupIndex= */ 0, /* splitIndexExclusive= */ 2, adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(2); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(20_000_000L); + assertThat(adPlaybackState.getAdGroup(0).durationsUs) + .asList() + .containsExactly(10_000_000L, 10_000_000L); + assertThat(adPlaybackState.getAdGroup(0).states) + .asList() + .containsExactly(AD_STATE_PLAYED, AD_STATE_PLAYED); + assertThat(adPlaybackState.getAdGroup(1).timeUs).isEqualTo(123_000_000 + 20_000_000); + assertThat(adPlaybackState.getAdGroup(1).count).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(1).originalCount).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(1).contentResumeOffsetUs).isEqualTo(15_000_000L); + assertThat(adPlaybackState.getAdGroup(1).durationsUs).asList().containsExactly(15_000_000L); + assertThat(adPlaybackState.getAdGroup(1).states).asList().containsExactly(AD_STATE_AVAILABLE); + } + + @Test + public void splitAdGroup_multipleTrailingAds_correctlySplit() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 1, + /* totalAdDurationUs= */ 10_000_000, + /* totalAdsInAdPod= */ 1, + adPlaybackState); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000 + 10_000_000, + /* adDurationUs= */ 20_000_000, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 100_000_000, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000 + 10_000_000 + 10_000_000, + /* adDurationUs= */ 30_000_000, + /* adPositionInAdPod= */ 3, + /* totalAdDurationUs= */ 100_000_000, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ 0); + + // Split the current adGroup at ad index 1: + // [AD_STATE_PLAYED, AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE] + adPlaybackState = + splitAdGroup(adGroup, /* adGroupIndex= */ 0, /* splitIndexExclusive= */ 1, adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(10_000_000L); + assertThat(adPlaybackState.getAdGroup(0).durationsUs).asList().containsExactly(10_000_000L); + assertThat(adPlaybackState.getAdGroup(0).states).asList().containsExactly(AD_STATE_PLAYED); + assertThat(adPlaybackState.getAdGroup(1).timeUs).isEqualTo(123_000_000 + 10_000_000); + assertThat(adPlaybackState.getAdGroup(1).count).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(1).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(1).contentResumeOffsetUs).isEqualTo(100_000_000L); + assertThat(adPlaybackState.getAdGroup(1).durationsUs) + .asList() + .containsExactly(20_000_000L, 30_000_000L, 50_000_000L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(1).states) + .asList() + .containsExactly(AD_STATE_AVAILABLE, AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + } + + @Test + public void splitAdGroup_lastAdWithZeroDuration_correctlySplit() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000, + /* adDurationUs= */ 10_000_000, + /* adPositionInAdPod= */ 1, + /* totalAdDurationUs= */ 10_000_000, + /* totalAdsInAdPod= */ 1, + adPlaybackState); + adPlaybackState = + addLiveAdBreak( + /* currentContentPeriodPositionUs= */ 123_000_000 + 10_000_000, + /* adDurationUs= */ 20_000_000, + /* adPositionInAdPod= */ 2, + /* totalAdDurationUs= */ 100_000_000, + /* totalAdsInAdPod= */ 4, + adPlaybackState); + adPlaybackState = + adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ 0); + + // Split the current adGroup at ad index 1: + // [AD_STATE_PLAYED, AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE] + adPlaybackState = + splitAdGroup(adGroup, /* adGroupIndex= */ 0, /* splitIndexExclusive= */ 1, adPlaybackState); + + assertThat(adPlaybackState.adGroupCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(0).timeUs).isEqualTo(123_000_000); + assertThat(adPlaybackState.getAdGroup(0).count).isEqualTo(1); + assertThat(adPlaybackState.getAdGroup(0).originalCount).isEqualTo(4); + assertThat(adPlaybackState.getAdGroup(0).contentResumeOffsetUs).isEqualTo(10_000_000L); + assertThat(adPlaybackState.getAdGroup(0).durationsUs).asList().containsExactly(10_000_000L); + assertThat(adPlaybackState.getAdGroup(0).states).asList().containsExactly(AD_STATE_PLAYED); + assertThat(adPlaybackState.getAdGroup(1).timeUs).isEqualTo(123_000_000 + 10_000_000); + assertThat(adPlaybackState.getAdGroup(1).count).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(1).originalCount).isEqualTo(3); + assertThat(adPlaybackState.getAdGroup(1).contentResumeOffsetUs).isEqualTo(100_000_000L); + assertThat(adPlaybackState.getAdGroup(1).durationsUs) + .asList() + .containsExactly(20_000_000L, 80_000_000L, 0L) + .inOrder(); + assertThat(adPlaybackState.getAdGroup(1).states) + .asList() + .containsExactly(AD_STATE_AVAILABLE, AD_STATE_UNAVAILABLE, AD_STATE_UNAVAILABLE) + .inOrder(); + } }