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));