From 593c6fa1e846fd448294162e49c882a209baa950 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 11 Mar 2025 06:21:05 -0700 Subject: [PATCH] Map live interstitials to ad playback state After this change, updates of the live HLS playlists are reflected in the ad playback state. Interstitials are inserted into new or existing ad groups according to the current ad playback state. PiperOrigin-RevId: 735733207 --- .../hls/HlsInterstitialsAdsLoader.java | 231 ++++-- .../hls/HlsInterstitialsAdsLoaderTest.java | 693 +++++++++++++++--- 2 files changed, 780 insertions(+), 144 deletions(-) diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java index d62dafe4ba..20e782c141 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java @@ -15,11 +15,14 @@ */ package androidx.media3.exoplayer.hls; +import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE; import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; 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.Assertions.checkStateNotNull; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST; +import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE; import static java.lang.Math.max; import android.content.Context; @@ -48,6 +51,7 @@ import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.ads.AdsLoader; import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.util.ArrayList; @@ -283,6 +287,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { private final PlayerListener playerListener; private final Map activeEventListeners; private final Map activeAdPlaybackStates; + private final Map> insertedInterstitialIds; private final List listeners; private final Set unsupportedAdsIds; @@ -294,6 +299,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { playerListener = new PlayerListener(); activeEventListeners = new HashMap<>(); activeAdPlaybackStates = new HashMap<>(); + insertedInterstitialIds = new HashMap<>(); listeners = new ArrayList<>(); unsupportedAdsIds = new HashSet<>(); } @@ -366,16 +372,15 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } activeEventListeners.put(adsId, eventListener); MediaItem mediaItem = adsMediaSource.getMediaItem(); - if (player != null && isSupportedMediaItem(mediaItem, player.getCurrentTimeline())) { + if (isHlsMediaItem(mediaItem)) { // Mark with NONE. Update and notify later when timeline with interstitials arrives. activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE); + insertedInterstitialIds.put(adsId, new HashSet<>()); notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider)); } else { putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId)); - if (player != null) { - Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId); - unsupportedAdsIds.add(adsId); - } + Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId); + unsupportedAdsIds.add(adsId); } } @@ -387,6 +392,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { if (eventListener != null) { unsupportedAdsIds.remove(adsId); AdPlaybackState adPlaybackState = checkNotNull(activeAdPlaybackStates.remove(adsId)); + insertedInterstitialIds.remove(adsId); if (adPlaybackState.equals(AdPlaybackState.NONE)) { // Play without ads after release to not interrupt playback. eventListener.onAdPlaybackState(new AdPlaybackState(adsId)); @@ -394,17 +400,35 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } return; } + AdPlaybackState adPlaybackState = checkNotNull(activeAdPlaybackStates.get(adsId)); - if (!adPlaybackState.equals(AdPlaybackState.NONE)) { - // VOD only. Updating the playback state is not supported yet. + if (!adPlaybackState.equals(AdPlaybackState.NONE) + && !adPlaybackState.endsWithLivePostrollPlaceHolder()) { + // Multiple timeline updates for VOD not supported. return; } - adPlaybackState = new AdPlaybackState(adsId); + + if (adPlaybackState.equals(AdPlaybackState.NONE)) { + // Setup initial ad playback state for VOD or live. + adPlaybackState = new AdPlaybackState(adsId); + if (isLiveMediaItem(adsMediaSource.getMediaItem(), timeline)) { + adPlaybackState = + adPlaybackState.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false); + } + } + Window window = timeline.getWindow(0, new Window()); if (window.manifest instanceof HlsManifest) { + HlsMediaPlaylist mediaPlaylist = ((HlsManifest) window.manifest).mediaPlaylist; adPlaybackState = - mapHlsInterstitialsToAdPlaybackState( - ((HlsManifest) window.manifest).mediaPlaylist, adPlaybackState); + window.isLive() + ? mapInterstitialsForLive( + mediaPlaylist, + adPlaybackState, + window.positionInFirstPeriodUs, + checkNotNull(insertedInterstitialIds.get(adsId))) + : mapInterstitialsForVod( + mediaPlaylist, adPlaybackState, checkNotNull(insertedInterstitialIds.get(adsId))); } putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); if (!unsupportedAdsIds.contains(adsId)) { @@ -464,6 +488,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { adsMediaSource.getAdsId(), checkNotNull(adPlaybackState))); } + insertedInterstitialIds.remove(adsId); unsupportedAdsIds.remove(adsId); } @@ -488,6 +513,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { eventListener.onAdPlaybackState(adPlaybackState); } else { activeAdPlaybackStates.remove(adsId); + insertedInterstitialIds.remove(adsId); } } } @@ -498,10 +524,6 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } } - private static boolean isSupportedMediaItem(MediaItem mediaItem, Timeline timeline) { - return isHlsMediaItem(mediaItem) && !isLiveMediaItem(mediaItem, timeline); - } - private static boolean isLiveMediaItem(MediaItem mediaItem, Timeline timeline) { int windowIndex = timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ false); Window window = new Window(); @@ -523,68 +545,161 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { || Util.inferContentType(localConfiguration.uri) == C.CONTENT_TYPE_HLS; } - private static AdPlaybackState mapHlsInterstitialsToAdPlaybackState( - HlsMediaPlaylist hlsMediaPlaylist, AdPlaybackState adPlaybackState) { - for (int i = 0; i < hlsMediaPlaylist.interstitials.size(); i++) { - Interstitial interstitial = hlsMediaPlaylist.interstitials.get(i); + private static AdPlaybackState mapInterstitialsForLive( + HlsMediaPlaylist mediaPlaylist, + AdPlaybackState adPlaybackState, + long windowPositionInPeriodUs, + Set insertedInterstitialIds) { + ArrayList interstitials = new ArrayList<>(mediaPlaylist.interstitials); + for (int i = 0; i < interstitials.size(); i++) { + Interstitial interstitial = interstitials.get(i); + long positionInPlaylistWindowUs = + interstitial.cue.contains(CUE_TRIGGER_PRE) + ? 0L + : (interstitial.startDateUnixUs - mediaPlaylist.startTimeUs); + if (interstitial.assetUri == null + || insertedInterstitialIds.contains(interstitial.id) + || interstitial.cue.contains(CUE_TRIGGER_POST) + || positionInPlaylistWindowUs < 0) { + continue; + } + long timeUs = windowPositionInPeriodUs + positionInPlaylistWindowUs; + int insertionIndex = adPlaybackState.adGroupCount - 1; + boolean isNewAdGroup = true; + for (int adGroupIndex = adPlaybackState.adGroupCount - 2; // skip live placeholder + adGroupIndex >= adPlaybackState.removedAdGroupCount; + adGroupIndex--) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + if (adGroup.timeUs == timeUs) { + // Insert interstitials into or update in existing group. + insertionIndex = adGroupIndex; + isNewAdGroup = false; + break; + } else if (adGroup.timeUs < timeUs) { + // Insert at index after group before interstitial. + insertionIndex = adGroupIndex + 1; + break; + } + // Interstitial is before the ad group. Possible insertion index. + insertionIndex = adGroupIndex; + } + if (isNewAdGroup) { + if (insertionIndex < getLowestValidAdGroupInsertionIndex(adPlaybackState)) { + Log.w( + TAG, + "Skipping insertion of interstitial attempted to be inserted before an already" + + " initialized ad group."); + continue; + } + adPlaybackState = adPlaybackState.withNewAdGroup(insertionIndex, timeUs); + } + adPlaybackState = + insertOrUpdateInterstitialInAdGroup( + interstitial, /* adGroupIndex= */ insertionIndex, adPlaybackState); + insertedInterstitialIds.add(interstitial.id); + } + return adPlaybackState; + } + + private static AdPlaybackState mapInterstitialsForVod( + HlsMediaPlaylist mediaPlaylist, + AdPlaybackState adPlaybackState, + Set insertedInterstitialIds) { + checkArgument(adPlaybackState.adGroupCount == 0); + ImmutableList interstitials = mediaPlaylist.interstitials; + for (int i = 0; i < interstitials.size(); i++) { + Interstitial interstitial = interstitials.get(i); if (interstitial.assetUri == null) { Log.w(TAG, "Ignoring interstitials with X-ASSET-LIST. Not yet supported."); continue; } - long positionUs; - if (interstitial.cue.contains(Interstitial.CUE_TRIGGER_PRE)) { - positionUs = 0; - } else if (interstitial.cue.contains(Interstitial.CUE_TRIGGER_POST)) { - positionUs = C.TIME_END_OF_SOURCE; + long timeUs; + if (interstitial.cue.contains(CUE_TRIGGER_PRE)) { + timeUs = 0L; + } else if (interstitial.cue.contains(CUE_TRIGGER_POST)) { + timeUs = C.TIME_END_OF_SOURCE; } else { - positionUs = interstitial.startDateUnixUs - hlsMediaPlaylist.startTimeUs; + timeUs = interstitial.startDateUnixUs - mediaPlaylist.startTimeUs; } - // Check whether and at which index to insert an ad group for the interstitial start time. int adGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - positionUs, /* periodDurationUs= */ hlsMediaPlaylist.durationUs); + adPlaybackState.getAdGroupIndexForPositionUs(timeUs, mediaPlaylist.durationUs); if (adGroupIndex == C.INDEX_UNSET) { // There is no ad group before or at the interstitials position. adGroupIndex = 0; - adPlaybackState = adPlaybackState.withNewAdGroup(0, positionUs); - } else if (adPlaybackState.getAdGroup(adGroupIndex).timeUs != positionUs) { + adPlaybackState = adPlaybackState.withNewAdGroup(/* adGroupIndex= */ 0, timeUs); + } else if (adPlaybackState.getAdGroup(adGroupIndex).timeUs != timeUs) { // There is an ad group before the interstitials. Insert after that index. adGroupIndex++; - adPlaybackState = adPlaybackState.withNewAdGroup(adGroupIndex, positionUs); + adPlaybackState = adPlaybackState.withNewAdGroup(adGroupIndex, timeUs); } - - int adIndexInAdGroup = max(adPlaybackState.getAdGroup(adGroupIndex).count, 0); - - // Insert duration of new interstitial into existing ad durations. - long interstitialDurationUs = - getInterstitialDurationUs(interstitial, /* defaultDurationUs= */ C.TIME_UNSET); - long[] adDurations; - if (adIndexInAdGroup == 0) { - adDurations = new long[1]; - } else { - long[] previousDurations = adPlaybackState.getAdGroup(adGroupIndex).durationsUs; - adDurations = new long[previousDurations.length + 1]; - System.arraycopy(previousDurations, 0, adDurations, 0, previousDurations.length); - } - adDurations[adDurations.length - 1] = interstitialDurationUs; - - long resumeOffsetIncrementUs = - interstitial.resumeOffsetUs != C.TIME_UNSET - ? interstitial.resumeOffsetUs - : (interstitialDurationUs != C.TIME_UNSET ? interstitialDurationUs : 0L); - long resumeOffsetUs = - adPlaybackState.getAdGroup(adGroupIndex).contentResumeOffsetUs + resumeOffsetIncrementUs; adPlaybackState = - adPlaybackState - .withAdCount(adGroupIndex, /* adCount= */ adIndexInAdGroup + 1) - .withAdDurationsUs(adGroupIndex, adDurations) - .withContentResumeOffsetUs(adGroupIndex, resumeOffsetUs) - .withAvailableAdMediaItem( - adGroupIndex, adIndexInAdGroup, MediaItem.fromUri(interstitial.assetUri)); + insertOrUpdateInterstitialInAdGroup(interstitial, adGroupIndex, adPlaybackState); + insertedInterstitialIds.add(interstitial.id); } return adPlaybackState; } + private static AdPlaybackState insertOrUpdateInterstitialInAdGroup( + Interstitial interstitial, int adGroupIndex, AdPlaybackState adPlaybackState) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + int adIndexInAdGroup = adGroup.getIndexOfAdId(interstitial.id); + if (adIndexInAdGroup != C.INDEX_UNSET) { + // Interstitial already inserted. Updating not yet supported. + return adPlaybackState; + } + + // Append to the end of the group. + adIndexInAdGroup = max(adGroup.count, 0); + // Append duration of new interstitial into existing ad durations. + long interstitialDurationUs = + getInterstitialDurationUs(interstitial, /* defaultDurationUs= */ C.TIME_UNSET); + long[] adDurations; + if (adIndexInAdGroup == 0) { + adDurations = new long[1]; + } else { + long[] previousDurations = adGroup.durationsUs; + adDurations = new long[previousDurations.length + 1]; + System.arraycopy(previousDurations, 0, adDurations, 0, previousDurations.length); + } + adDurations[adDurations.length - 1] = interstitialDurationUs; + long resumeOffsetIncrementUs = + interstitial.resumeOffsetUs != C.TIME_UNSET + ? interstitial.resumeOffsetUs + : (interstitialDurationUs != C.TIME_UNSET ? interstitialDurationUs : 0L); + long resumeOffsetUs = adGroup.contentResumeOffsetUs + resumeOffsetIncrementUs; + adPlaybackState = + adPlaybackState + .withAdCount(adGroupIndex, adIndexInAdGroup + 1) + .withAdId(adGroupIndex, adIndexInAdGroup, interstitial.id) + .withAdDurationsUs(adGroupIndex, adDurations) + .withContentResumeOffsetUs(adGroupIndex, resumeOffsetUs); + if (interstitial.assetUri != null) { + adPlaybackState = + adPlaybackState.withAvailableAdMediaItem( + adGroupIndex, + adIndexInAdGroup, + new MediaItem.Builder() + .setUri(interstitial.assetUri) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()); + } + return adPlaybackState; + } + + private static int getLowestValidAdGroupInsertionIndex(AdPlaybackState adPlaybackState) { + for (int adGroupIndex = adPlaybackState.adGroupCount - 1; + adGroupIndex >= adPlaybackState.removedAdGroupCount; + adGroupIndex--) { + for (@AdPlaybackState.AdState int state : adPlaybackState.getAdGroup(adGroupIndex).states) { + if (state != AD_STATE_UNAVAILABLE) { + return adGroupIndex + 1; + } + } + } + // All ad groups unavailable. + return adPlaybackState.removedAdGroupCount; + } + private static long getInterstitialDurationUs(Interstitial interstitial, long defaultDurationUs) { if (interstitial.playoutLimitUs != C.TIME_UNSET) { return interstitial.playoutLimitUs; diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoaderTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoaderTest.java index 957dd0b580..ce5423bb13 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoaderTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoaderTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atMost; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; @@ -36,6 +37,7 @@ import androidx.media3.common.AdViewProvider; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.Metadata; +import androidx.media3.common.MimeTypes; import androidx.media3.common.Player; import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSpec; @@ -45,12 +47,14 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.ads.AdsLoader; import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.media3.test.utils.FakeTimeline; +import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.List; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -76,8 +80,8 @@ public class HlsInterstitialsAdsLoaderTest { private MediaItem contentMediaItem; private DataSpec adTagDataSpec; private AdsMediaSource adsMediaSource; - private FakeTimeline.TimelineWindowDefinition contentWindowDefinition; - private FakeTimeline.TimelineWindowDefinition adsMediaSourceWindowDefinition; + private TimelineWindowDefinition contentWindowDefinition; + private TimelineWindowDefinition adsMediaSourceWindowDefinition; @Before public void setUp() { @@ -101,13 +105,13 @@ public class HlsInterstitialsAdsLoaderTest { .createMediaSource(contentMediaItem); // The content timeline with empty ad playback state. contentWindowDefinition = - new FakeTimeline.TimelineWindowDefinition.Builder() + new TimelineWindowDefinition.Builder() .setDurationUs(90_000_000L) .setMediaItem(contentMediaItem) .build(); // The ads timeline with a minimal ad playback state with the ads ID. adsMediaSourceWindowDefinition = - new FakeTimeline.TimelineWindowDefinition.Builder() + new TimelineWindowDefinition.Builder() .setDurationUs(90_000_000L) .setMediaItem(contentMediaItem) .setAdPlaybackStates(ImmutableList.of(new AdPlaybackState("adsId"))) @@ -152,7 +156,7 @@ public class HlsInterstitialsAdsLoaderTest { when(mockPlayer.getCurrentTimeline()) .thenReturn( new FakeTimeline( - new FakeTimeline.TimelineWindowDefinition.Builder() + new TimelineWindowDefinition.Builder() .setDynamic(true) .setDurationUs(C.TIME_UNSET) .setMediaItem(mp4MediaItem) @@ -164,24 +168,6 @@ public class HlsInterstitialsAdsLoaderTest { verify(mockEventListener).onAdPlaybackState(new AdPlaybackState("adsId")); } - @Test - public void start_liveWindow_emptyAdPlaybackState() { - when(mockPlayer.getCurrentTimeline()) - .thenReturn( - new FakeTimeline( - new FakeTimeline.TimelineWindowDefinition.Builder() - .setDynamic(true) - .setLive(true) - .setDurationUs(C.TIME_UNSET) - .setMediaItem(contentMediaItem) - .build())); - adsLoader.setPlayer(mockPlayer); - - adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); - - verify(mockEventListener).onAdPlaybackState(new AdPlaybackState("adsId")); - } - @Test public void start_twiceWithIdenticalAdsId_throwIllegalStateException() { when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); @@ -220,7 +206,7 @@ public class HlsInterstitialsAdsLoaderTest { when(mockPlayer.getCurrentTimeline()) .thenReturn( new FakeTimeline( - new FakeTimeline.TimelineWindowDefinition.Builder() + new TimelineWindowDefinition.Builder() .setDynamic(true) .setDurationUs(C.TIME_UNSET) .setMediaItem(mp4MediaItem) @@ -249,49 +235,63 @@ public class HlsInterstitialsAdsLoaderTest { + "#EXT-X-ENDLIST" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad0\"," + + "ID=\"ad0-0\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + "CUE=\"PRE\"," - + "X-ASSET-URI=\"http://example.com/media-0.m3u8\"" + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad1\"," + + "ID=\"ad1-0\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:55.000Z\"," - + "X-ASSET-URI=\"http://example.com/media-1.m3u8\"" + + "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\"" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad2\"," + + "ID=\"ad2-0\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + "CUE=\"POST\"," - + "X-ASSET-URI=\"http://example.com/media-2.m3u8\"\n"; + + "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\"\n"; - assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) - .isEqualTo( - new AdPlaybackState("adsId", 0L, 15_000_000L, C.TIME_END_OF_SOURCE) - .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET) - .withAdDurationsUs(/* adGroupIndex= */ 1, C.TIME_UNSET) - .withAdDurationsUs(/* adGroupIndex= */ 2, C.TIME_UNSET) - .withAdCount(/* adGroupIndex= */ 0, 1) - .withAdCount(/* adGroupIndex= */ 1, 1) - .withAdCount(/* adGroupIndex= */ 2, 1) - .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) - .withContentResumeOffsetUs(/* adGroupIndex= */ 1, 0L) - .withContentResumeOffsetUs(/* adGroupIndex= */ 2, 0L) - .withAvailableAdMediaItem( - /* adGroupIndex= */ 0, - /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0.m3u8")) - .withAvailableAdMediaItem( - /* adGroupIndex= */ 1, - /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-1.m3u8")) - .withAvailableAdMediaItem( - /* adGroupIndex= */ 2, - /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-2.m3u8"))); + AdPlaybackState actual = + callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + AdPlaybackState expected = + new AdPlaybackState("adsId", 0L, 15_000_000L, C.TIME_END_OF_SOURCE) + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET) + .withAdDurationsUs(/* adGroupIndex= */ 1, C.TIME_UNSET) + .withAdDurationsUs(/* adGroupIndex= */ 2, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdCount(/* adGroupIndex= */ 1, 1) + .withAdCount(/* adGroupIndex= */ 2, 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 1, 0L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 2, 0L) + .withAdId(0, 0, "ad0-0") + .withAdId(1, 0, "ad1-0") + .withAdId(2, 0, "ad2-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-1-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 2, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-2-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()); + assertThat(actual).isEqualTo(expected); } @Test @@ -331,18 +331,30 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET) .withAdCount(/* adGroupIndex= */ 0, 3) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, "ad0-2") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, - MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, - MediaItem.fromUri("http://example.com/media-0-2.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-2.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -381,18 +393,30 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET) .withAdCount(/* adGroupIndex= */ 0, 3) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, "ad0-2") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, - MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, - MediaItem.fromUri("http://example.com/media-0-2.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-2.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -437,18 +461,30 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, 1_100_000L, 1_200_000L) .withAdCount(/* adGroupIndex= */ 0, 3) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_300_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, "ad0-2") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, - MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, - MediaItem.fromUri("http://example.com/media-0-2.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-2.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -463,7 +499,7 @@ public class HlsInterstitialsAdsLoaderTest { + "#EXT-X-ENDLIST" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad0-2\"," + + "ID=\"ad2-0\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:40.500Z\"," + "CUE=\"POST\"," @@ -471,7 +507,7 @@ public class HlsInterstitialsAdsLoaderTest { + "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\"" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad0-1\"," + + "ID=\"ad1-0\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:42.000Z\"," + "DURATION=2.0," @@ -498,18 +534,30 @@ public class HlsInterstitialsAdsLoaderTest { .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000_000L) .withContentResumeOffsetUs(/* adGroupIndex= */ 1, 2_000_000L) .withContentResumeOffsetUs(/* adGroupIndex= */ 2, 3_000_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0") + .withAdId(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, "ad2-0") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-1-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-1-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-2-0.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-2-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -533,7 +581,7 @@ public class HlsInterstitialsAdsLoaderTest { + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad0-0\"," + + "ID=\"ad0-1\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + "DURATION=1.0," @@ -547,14 +595,22 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, 1_000_000L) .withAdCount(/* adGroupIndex= */ 0, 2) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, - MediaItem.fromUri("http://example.com/media-0-1.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -578,7 +634,7 @@ public class HlsInterstitialsAdsLoaderTest { + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + "\n" + "#EXT-X-DATERANGE:" - + "ID=\"ad0-0\"," + + "ID=\"ad0-1\"," + "CLASS=\"com.apple.hls.interstitial\"," + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + "CUE=\"PRE\"," @@ -591,14 +647,22 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, C.TIME_UNSET) .withAdCount(/* adGroupIndex= */ 0, 2) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, - MediaItem.fromUri("http://example.com/media-0-1.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -629,10 +693,14 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 4_000_000L) .withAdCount(/* adGroupIndex= */ 0, 1) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 4_000_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -662,10 +730,14 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 3_456_000L) .withAdCount(/* adGroupIndex= */ 0, 1) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_456_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -693,10 +765,14 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 1_123_000L) .withAdCount(/* adGroupIndex= */ 0, 1) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_123_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -724,10 +800,14 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, 2_234_000L) .withAdCount(/* adGroupIndex= */ 0, 1) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 2_234_000L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); } @Test @@ -753,10 +833,391 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET) .withAdCount(/* adGroupIndex= */ 0, 1) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); + } + + @Test + public void handleContentTimelineChanged_livePlaylistWithoutInterstitials_hasLivePlaceholder() + throws IOException { + assertThat( + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ true, + /* windowOffsetInFirstPeriodUs= */ 0L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:6,\nmain0.0.ts\n" + + "#EXTINF:6,\nmain1.0.ts\n" + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" + + "#EXTINF:6,\nmain4.0.ts\n" + + "\n", + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:06.000Z\n" + + "#EXTINF:6,\nmain1.0.ts\n" + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" + + "#EXTINF:6,\nmain4.0.ts\n" + + "#EXTINF:6,\nmain5.0.ts\n" + + "\n")) + .containsExactly( + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)); + } + + @Test + public void + handleContentTimelineChanged_threeLivePlaylistUpdatesUnplayed_correctAdPlaybackStateUpdates() + throws IOException { + assertThat( + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ true, + /* windowOffsetInFirstPeriodUs= */ 0L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:06.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:6,\nmain0.0.ts\n" + + "#EXTINF:6,\nmain1.0.ts\n" // ad0-0 cue point: 21:00:06 + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" + + "#EXTINF:6,\nmain4.0.ts\n" + + "\n", + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:06.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:18.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\"\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:06.000Z\n" + + "#EXTINF:6,\nmain1.0.ts\n" // ad0-0 cue point: 21:00:06 + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" // ad1-0 cue point: 21:00:18 + + "#EXTINF:6,\nmain4.0.ts\n" + + "#EXTINF:6,\nmain5.0.ts\n" + + "\n", + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:2\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:18.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\"\n" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:18.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-1-1.m3u8\"\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:12.000Z\n" + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" // ad1-0 cue point: 21:00:18 + + "#EXTINF:6,\nmain4.0.ts\n" + + "#EXTINF:6,\nmain5.0.ts\n" + + "#EXTINF:6,\nmain6.0.ts\n" + + "\n")) + .containsExactly( + new AdPlaybackState("adsId", 6_000_000L) + .withAdResumePositionUs(0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false), + new AdPlaybackState("adsId", 6_000_000L) + .withAdResumePositionUs(0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withNewAdGroup(1, 18_000_000L) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdId(1, 0, "ad1-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-1-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false), + new AdPlaybackState("adsId", 6_000_000L) + .withAdResumePositionUs(0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withNewAdGroup(1, 18_000_000L) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 2) + .withAdId(1, 0, "ad1-0") + .withAdId(1, 1, "ad1-1") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-1-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 1, + new MediaItem.Builder() + .setUri("http://example.com/media-1-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)) + .inOrder(); + } + + @Test + public void + handleContentTimelineChanged_livePlaylistUpdateNewAdAfterPlayedAd_correctAdPlaybackStateUpdates() + throws IOException { + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ true, + /* windowOffsetInFirstPeriodUs= */ 0L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:06.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:6,\nmain0.0.ts\n" + + "#EXTINF:6,\nmain1.0.ts\n" // ad0-0 cue point: 21:00:06 + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" + + "#EXTINF:6,\nmain4.0.ts\n" + + "\n"); + reset(mockEventListener); + // Mark ad as played by a automatic discontinuity from the ad to the content. + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + verify(mockPlayer).addListener(listener.capture()); + Object windowUid = new Object(); + Object periodUid = new Object(); + listener + .getValue() + .onPositionDiscontinuity( + new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 0, + contentMediaItem, + periodUid, + /* periodIndex= */ 0, + /* positionMs= */ 10_000L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0), + new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 0, + contentMediaItem, + periodUid, + /* periodIndex= */ 0, + /* positionMs= */ 0L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + DISCONTINUITY_REASON_AUTO_TRANSITION); + verify(mockEventListener) + .onAdPlaybackState( + new AdPlaybackState("adsId", 6_000_000L) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false) + .withAdResumePositionUs(0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + reset(mockEventListener); + + assertThat( + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ false, + /* windowOffsetInFirstPeriodUs= */ 6_000_000L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:06.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:18.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\"\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:06.000Z\n" + + "#EXTINF:6,\nmain1.0.ts\n" // ad0-0 cue point: 21:00:06 + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" + + "#EXTINF:6,\nmain4.0.ts\n" + + "#EXTINF:6,\nmain5.0.ts\n" // ad1-0 cue point: 21:00:30 + + "\n")) + .containsExactly( + new AdPlaybackState("adsId", 6_000_000L) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false) + .withAdResumePositionUs(0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withNewAdGroup(1, 18_000_000L) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdId(1, 0, "ad1-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-1-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build())); + } + + @Test + public void + handleContentTimelineChanged_attemptInsertionForLiveBeforeAvailableAdGroup_interstitialIgnored() + throws IOException { + assertThat( + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ true, + /* windowOffsetInFirstPeriodUs= */ 0L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:18.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:6,\nmain0.0.ts\n" + + "#EXTINF:6,\nmain1.0.ts\n" + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" // ad0-0 cue point: 21:00:18 + + "#EXTINF:6,\nmain4.0.ts\n" + + "\n", + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:06.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\"" + + "\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:06.000Z\n" + + "#EXTINF:6,\nmain1.0.ts\n" // ad1-0 cue point: 21:00:06 + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" // ad0-0 cue point: 21:00:18 + + "#EXTINF:6,\nmain4.0.ts\n" + + "#EXTINF:6,\nmain5.0.ts\n" + + "\n")) + .containsExactly( + new AdPlaybackState("adsId", 18_000_000L) + .withAdResumePositionUs(0) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)) + .inOrder(); + } + + @Test + public void handleContentTimelineChanged_attemptInsertionBehindLiveWindow_interstitialIgnored() + throws IOException { + assertThat( + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ true, + /* windowOffsetInFirstPeriodUs= */ 0L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:00.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.001Z\n" + + "#EXTINF:6,\nmain0.0.ts\n" + + "#EXTINF:6,\nmain1.0.ts\n" + + "#EXTINF:6,\nmain2.0.ts\n" + + "\n")) + .containsExactly( + new AdPlaybackState("adsId") + .withAdResumePositionUs(0) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)) + .inOrder(); } @Test @@ -832,8 +1293,8 @@ public class HlsInterstitialsAdsLoaderTest { /* periodIndex= */ 0, /* positionMs= */ 0L, /* contentPositionMs= */ 0L, - /* adGroupIndex= */ -1, - /* adIndexInAdGroup= */ -1), + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), DISCONTINUITY_REASON_AUTO_TRANSITION); verify(mockAdsLoaderListener) @@ -854,14 +1315,22 @@ public class HlsInterstitialsAdsLoaderTest { .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2) .withContentResumeOffsetUs(/* adGroupIndex= */ 0, /* contentResumeOffsetUs= */ 0) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1") .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, - MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-1.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)); } @@ -884,6 +1353,7 @@ public class HlsInterstitialsAdsLoaderTest { + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + "\n"; callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + reset(mockEventListener); ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); when(mockPlayer.getCurrentTimeline()) .thenReturn(new FakeTimeline(adsMediaSourceWindowDefinition)); @@ -911,7 +1381,11 @@ public class HlsInterstitialsAdsLoaderTest { .withAvailableAdMediaItem( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, - MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-1") .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } @@ -1032,7 +1506,6 @@ public class HlsInterstitialsAdsLoaderTest { ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); InOrder inOrder = inOrder(mockPlayer); inOrder.verify(mockPlayer).addListener(listener.capture()); - inOrder.verify(mockPlayer).getCurrentTimeline(); inOrder.verifyNoMoreInteractions(); reset(mockPlayer); @@ -1059,7 +1532,6 @@ public class HlsInterstitialsAdsLoaderTest { ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); InOrder inOrder = inOrder(mockPlayer); inOrder.verify(mockPlayer).addListener(listener.capture()); - inOrder.verify(mockPlayer).getCurrentTimeline(); inOrder.verifyNoMoreInteractions(); reset(mockPlayer); @@ -1334,6 +1806,55 @@ public class HlsInterstitialsAdsLoaderTest { verifyNoMoreInteractions(mockEventListener); } + private List callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + HlsInterstitialsAdsLoader adsLoader, + boolean startAdsLoader, + long windowOffsetInFirstPeriodUs, + String... playlistStrings) + throws IOException { + if (startAdsLoader) { + // Set the player. + adsLoader.setPlayer(mockPlayer); + // Start the ad. + adsLoader.start( + adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + } + + HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser(); + long firstPlaylistStartTimeUs = C.TIME_UNSET; + for (String playlistString : playlistStrings) { + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + HlsMediaPlaylist mediaPlaylist = + (HlsMediaPlaylist) hlsPlaylistParser.parse(Uri.EMPTY, inputStream); + if (firstPlaylistStartTimeUs == C.TIME_UNSET) { + firstPlaylistStartTimeUs = mediaPlaylist.startTimeUs; + } + HlsManifest hlsManifest = new HlsManifest(/* multivariantPlaylist= */ null, mediaPlaylist); + adsLoader.handleContentTimelineChanged( + adsMediaSource, + new FakeTimeline( + new Object[] {hlsManifest}, + new TimelineWindowDefinition.Builder() + .setDynamic(true) + .setLive(true) + .setDurationUs(mediaPlaylist.durationUs) + .setDefaultPositionUs(mediaPlaylist.durationUs / 2) + .setWindowStartTimeUs(mediaPlaylist.startTimeUs) + .setWindowPositionInFirstPeriodUs( + windowOffsetInFirstPeriodUs + + (mediaPlaylist.startTimeUs - firstPlaylistStartTimeUs)) + .setMediaItem(contentMediaItem) + .build())); + } + ArgumentCaptor adPlaybackState = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, atMost(playlistStrings.length)) + .onAdPlaybackState(adPlaybackState.capture()); + when(mockPlayer.getCurrentTimeline()) + .thenReturn(new FakeTimeline(adsMediaSourceWindowDefinition)); + return adPlaybackState.getAllValues(); + } + private AdPlaybackState callHandleContentTimelineChangedAndCaptureAdPlaybackState( String playlistString, HlsInterstitialsAdsLoader adsLoader) throws IOException { InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString));