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 33223b6782..524c26b936 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,18 +15,28 @@ */ package androidx.media3.exoplayer.hls; +import static androidx.media3.common.AdPlaybackState.AD_STATE_AVAILABLE; import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE; import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_SKIP; +import static androidx.media3.common.Player.STATE_IDLE; 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.common.util.Util.castNonNull; +import static androidx.media3.common.util.Util.msToUs; +import static androidx.media3.common.util.Util.usToMs; 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; import android.net.Uri; +import android.os.Looper; import androidx.annotation.Nullable; import androidx.media3.common.AdPlaybackState; import androidx.media3.common.AdViewProvider; @@ -44,8 +54,11 @@ import androidx.media3.common.util.Consumer; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DataSpec; import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.PlayerMessage; import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial; @@ -53,6 +66,8 @@ 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 androidx.media3.exoplayer.upstream.Loader; +import androidx.media3.exoplayer.upstream.ParsingLoadable; import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; @@ -63,6 +78,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeMap; /** * An {@linkplain AdsLoader ads loader} that reads interstitials from the HLS playlist, adds them to @@ -448,22 +464,42 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { private static final String TAG = "HlsInterstitiaAdsLoader"; + private final DataSource.Factory dataSourceFactory; private final PlayerListener playerListener; private final Map activeEventListeners; private final Map activeAdPlaybackStates; private final Map> insertedInterstitialIds; + private final Map> unresolvedAssetLists; private final List listeners; private final Set unsupportedAdsIds; - @Nullable private Player player; + @Nullable private ExoPlayer player; + @Nullable private Loader loader; private boolean isReleased; + @Nullable private PlayerMessage pendingAssetListResolutionMessage; - /** Creates an instance. */ - public HlsInterstitialsAdsLoader() { + /** + * Creates an instance with a {@link DefaultDataSource.Factory} to read HLS X-ASSET-LIST JSON + * objects. + * + * @param context The context. + */ + public HlsInterstitialsAdsLoader(Context context) { + this(new DefaultDataSource.Factory(context)); + } + + /** + * Creates an instance. + * + * @param dataSourceFactory The data source factory to read HLS X-ASSET-LIST JSON objects. + */ + public HlsInterstitialsAdsLoader(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; playerListener = new PlayerListener(); activeEventListeners = new HashMap<>(); activeAdPlaybackStates = new HashMap<>(); insertedInterstitialIds = new HashMap<>(); + unresolvedAssetLists = new HashMap<>(); listeners = new ArrayList<>(); unsupportedAdsIds = new HashSet<>(); } @@ -492,6 +528,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { @Override public void setPlayer(@Nullable Player player) { checkState(!isReleased); + checkArgument(player == null || player instanceof ExoPlayer); if (Objects.equals(this.player, player)) { return; } @@ -499,7 +536,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { this.player.removeListener(playerListener); } checkState(player == null || activeEventListeners.isEmpty()); - this.player = player; + this.player = (ExoPlayer) player; } @Override @@ -540,6 +577,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { // Mark with NONE. Update and notify later when timeline with interstitials arrives. activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE); insertedInterstitialIds.put(adsId, new HashSet<>()); + unresolvedAssetLists.put(adsId, new TreeMap<>()); notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider)); } else { putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId)); @@ -584,15 +622,41 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { Window window = timeline.getWindow(0, new Window()); if (window.manifest instanceof HlsManifest) { HlsMediaPlaylist mediaPlaylist = ((HlsManifest) window.manifest).mediaPlaylist; + TreeMap assetListDataMap = checkNotNull(unresolvedAssetLists.get(adsId)); + int unresolvedAssetListCount = assetListDataMap.size(); adPlaybackState = window.isLive() ? mapInterstitialsForLive( + window.mediaItem, mediaPlaylist, adPlaybackState, window.positionInFirstPeriodUs, checkNotNull(insertedInterstitialIds.get(adsId))) : mapInterstitialsForVod( - mediaPlaylist, adPlaybackState, checkNotNull(insertedInterstitialIds.get(adsId))); + window.mediaItem, + mediaPlaylist, + adPlaybackState, + checkNotNull(insertedInterstitialIds.get(adsId))); + Player player = this.player; + if (unresolvedAssetListCount != assetListDataMap.size() + && player != null + && Objects.equals(window.mediaItem, player.getCurrentMediaItem())) { + long contentPositionUs; + if (window.isLive()) { + int currentPublicPeriodIndex = player.getCurrentPeriodIndex(); + Period publicPeriod = + player.getCurrentTimeline().getPeriod(currentPublicPeriodIndex, new Period()); + // Use the default position if this is the first timeline update. + contentPositionUs = + publicPeriod.isPlaceholder + ? window.defaultPositionUs + : msToUs(player.getContentPosition()); + } else { + contentPositionUs = msToUs(player.getContentPosition()); + } + maybeExecuteOrSetNextAssetListResolutionMessage( + adsId, timeline, /* windowIndex= */ 0, contentPositionUs); + } } putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); if (!unsupportedAdsIds.contains(adsId)) { @@ -654,6 +718,14 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } insertedInterstitialIds.remove(adsId); unsupportedAdsIds.remove(adsId); + unresolvedAssetLists.remove(adsId); + cancelPendingAssetListResolutionMessage(); + if (pendingAssetListResolutionMessage != null + && adsMediaSource + .getMediaItem() + .equals(castNonNull(pendingAssetListResolutionMessage).getPayload())) { + cancelPendingAssetListResolutionMessage(); + } } @Override @@ -663,11 +735,131 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { if (activeEventListeners.isEmpty()) { player = null; } + cancelPendingAssetListResolutionMessage(); + if (loader != null) { + loader.release(); + loader = null; + } isReleased = true; } // private methods + private void startLoadingAssetList(AssetListData assetListData) { + cancelPendingAssetListResolutionMessage(); + getLoader() + .startLoading( + new ParsingLoadable<>( + dataSourceFactory.createDataSource(), + checkNotNull(assetListData.interstitial.assetListUri), + C.DATA_TYPE_AD, + new AssetListParser()), + new LoaderCallback(assetListData), + /* defaultMinRetryCount= */ 1); + notifyListeners( + (listener) -> + listener.onAssetListLoadStarted( + assetListData.mediaItem, + assetListData.adsId, + assetListData.adGroupIndex, + assetListData.adIndexInAdGroup)); + } + + private void maybeExecuteOrSetNextAssetListResolutionMessage( + Object adsId, Timeline contentTimeline, int windowIndex, long windowPositionUs) { + if (loader != null && loader.isLoading()) { + return; + } + cancelPendingAssetListResolutionMessage(); + Window window = contentTimeline.getWindow(windowIndex, new Window()); + long currentPeriodPositionUs = window.positionInFirstPeriodUs + windowPositionUs; + RunnableAtPosition nextAssetResolution = getNextAssetResolution(adsId, currentPeriodPositionUs); + if (nextAssetResolution == null) { + return; + } + + long resolutionStartTimeUs = + nextAssetResolution.adStartTimeUs != Long.MAX_VALUE + ? nextAssetResolution.adStartTimeUs + : window.durationUs; + // Load 2 times the target duration before the ad starts. + resolutionStartTimeUs = + max( + currentPeriodPositionUs, + resolutionStartTimeUs - (2 * nextAssetResolution.targetDurationUs)); + if (resolutionStartTimeUs - currentPeriodPositionUs < 200_000L) { + // Start loading immediately. + nextAssetResolution.run(); + } else { + long messagePositionUs = resolutionStartTimeUs - window.positionInFirstPeriodUs; + pendingAssetListResolutionMessage = + checkNotNull(player) + .createMessage((messageType, message) -> nextAssetResolution.run()) + .setPayload(window.mediaItem) + .setLooper(checkNotNull(Looper.myLooper())) + .setPosition(usToMs(messagePositionUs)); + pendingAssetListResolutionMessage.send(); + } + } + + @Nullable + private RunnableAtPosition getNextAssetResolution(Object adsId, long periodPositionUs) { + TreeMap assetListDataMap = checkNotNull(unresolvedAssetLists.get(adsId)); + for (Long assetListTimeUs : assetListDataMap.keySet()) { + if (assetListDataMap.size() == 1 || periodPositionUs <= assetListTimeUs) { + AssetListData assetListData = checkNotNull(assetListDataMap.get(assetListTimeUs)); + return new RunnableAtPosition( + /* adStartTimeUs= */ assetListTimeUs, + assetListData.targetDurationUs, + () -> { + if (assetListDataMap.remove(assetListTimeUs) != null) { + startLoadingAssetList(assetListData); + } + }); + } + } + return null; + } + + private void cancelPendingAssetListResolutionMessage() { + if (pendingAssetListResolutionMessage != null) { + pendingAssetListResolutionMessage.cancel(); + pendingAssetListResolutionMessage = null; + } + } + + private long getUnresolvedAssetListWindowPositionForContentPositionUs( + long contentPositionUs, Timeline timeline, int periodIndex) { + Period period = timeline.getPeriod(periodIndex, new Period()); + long periodPositionUs = contentPositionUs - period.positionInWindowUs; + AdPlaybackState adPlaybackState = period.adPlaybackState; + int adGroupIndex = adPlaybackState.getAdGroupIndexForPositionUs(periodPositionUs, C.TIME_UNSET); + if (adGroupIndex != C.INDEX_UNSET) { + // Seek adjustment will snap to a playable ad behind the seek position. + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + TreeMap unresolvedAssets = + unresolvedAssetLists.get(adPlaybackState.adsId); + if (unresolvedAssets != null && unresolvedAssets.containsKey(adGroup.timeUs)) { + Window window = timeline.getWindow(period.windowIndex, new Window()); + return adGroup.timeUs - window.positionInFirstPeriodUs; + } + } + return C.TIME_UNSET; + } + + private void notifyListeners(Consumer callable) { + for (int i = 0; i < listeners.size(); i++) { + callable.accept(listeners.get(i)); + } + } + + private Loader getLoader() { + if (loader == null) { + loader = new Loader("HLS-interstitials"); + } + return loader; + } + private void putAndNotifyAdPlaybackStateUpdate(Object adsId, AdPlaybackState adPlaybackState) { @Nullable AdPlaybackState oldAdPlaybackState = activeAdPlaybackStates.put(adsId, adPlaybackState); @@ -682,10 +874,13 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } } - private void notifyListeners(Consumer callable) { - for (int i = 0; i < listeners.size(); i++) { - callable.accept(listeners.get(i)); + private void notifyAssetResolutionFailed(Object adsId, int adGroupIndex, int adIndexInAdGroup) { + AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(adsId); + if (adPlaybackState == null) { + return; } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); + putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); } private static boolean isLiveMediaItem(MediaItem mediaItem, Timeline timeline) { @@ -709,7 +904,8 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { || Util.inferContentType(localConfiguration.uri) == C.CONTENT_TYPE_HLS; } - private static AdPlaybackState mapInterstitialsForLive( + private AdPlaybackState mapInterstitialsForLive( + MediaItem mediaItem, HlsMediaPlaylist mediaPlaylist, AdPlaybackState adPlaybackState, long windowPositionInPeriodUs, @@ -721,8 +917,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { interstitial.cue.contains(CUE_TRIGGER_PRE) ? 0L : (interstitial.startDateUnixUs - mediaPlaylist.startTimeUs); - if (interstitial.assetUri == null - || insertedInterstitialIds.contains(interstitial.id) + if (insertedInterstitialIds.contains(interstitial.id) || interstitial.cue.contains(CUE_TRIGGER_POST) || positionInPlaylistWindowUs < 0) { continue; @@ -759,13 +954,18 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } adPlaybackState = insertOrUpdateInterstitialInAdGroup( - interstitial, /* adGroupIndex= */ insertionIndex, adPlaybackState); + mediaItem, + interstitial, + adPlaybackState, + /* adGroupIndex= */ insertionIndex, + mediaPlaylist.targetDurationUs); insertedInterstitialIds.add(interstitial.id); } return adPlaybackState; } - private static AdPlaybackState mapInterstitialsForVod( + private AdPlaybackState mapInterstitialsForVod( + MediaItem mediaItem, HlsMediaPlaylist mediaPlaylist, AdPlaybackState adPlaybackState, Set insertedInterstitialIds) { @@ -773,10 +973,6 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { 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 timeUs; if (interstitial.cue.contains(CUE_TRIGGER_PRE)) { timeUs = 0L; @@ -797,14 +993,23 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { adPlaybackState = adPlaybackState.withNewAdGroup(adGroupIndex, timeUs); } adPlaybackState = - insertOrUpdateInterstitialInAdGroup(interstitial, adGroupIndex, adPlaybackState); + insertOrUpdateInterstitialInAdGroup( + mediaItem, + interstitial, + adPlaybackState, + adGroupIndex, + mediaPlaylist.targetDurationUs); insertedInterstitialIds.add(interstitial.id); } return adPlaybackState; } - private static AdPlaybackState insertOrUpdateInterstitialInAdGroup( - Interstitial interstitial, int adGroupIndex, AdPlaybackState adPlaybackState) { + private AdPlaybackState insertOrUpdateInterstitialInAdGroup( + MediaItem mediaItem, + Interstitial interstitial, + AdPlaybackState adPlaybackState, + int adGroupIndex, + long playlistTargetDurationUs) { AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); int adIndexInAdGroup = adGroup.getIndexOfAdId(interstitial.id); if (adIndexInAdGroup != C.INDEX_UNSET) { @@ -846,6 +1051,18 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { .setUri(interstitial.assetUri) .setMimeType(MimeTypes.APPLICATION_M3U8) .build()); + } else { + Object adsId = checkNotNull(adPlaybackState.adsId); + checkNotNull(unresolvedAssetLists.get(adsId)) + .put( + adGroup.timeUs != C.TIME_END_OF_SOURCE ? adGroup.timeUs : Long.MAX_VALUE, + new AssetListData( + mediaItem, + adsId, + interstitial, + adGroupIndex, + adIndexInAdGroup, + playlistTargetDurationUs)); } return adPlaybackState; } @@ -902,24 +1119,56 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } @Override - public void onPositionDiscontinuity( - Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) { - if (reason != DISCONTINUITY_REASON_AUTO_TRANSITION - || player == null - || oldPosition.mediaItem == null - || oldPosition.adGroupIndex == C.INDEX_UNSET) { - return; - } - player.getCurrentTimeline().getPeriod(oldPosition.periodIndex, period); - @Nullable Object adsId = period.adPlaybackState.adsId; - if (adsId != null && activeAdPlaybackStates.containsKey(adsId)) { - markAdAsPlayedAndNotifyListeners( - oldPosition.mediaItem, adsId, oldPosition.adGroupIndex, oldPosition.adIndexInAdGroup); + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + if (timeline.isEmpty()) { + cancelPendingAssetListResolutionMessage(); } } @Override - public void onPlaybackStateChanged(int playbackState) { + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (player == null + || oldPosition.mediaItem == null + || newPosition.mediaItem == null + || reason == DISCONTINUITY_REASON_REMOVE) { + cancelPendingAssetListResolutionMessage(); + return; + } + Timeline currentTimeline = player.getCurrentTimeline(); + AdPlaybackState adPlaybackState = + currentTimeline.getPeriod(newPosition.periodIndex, period).adPlaybackState; + @Nullable Object adsId = adPlaybackState.adsId; + if (adsId == null || !activeAdPlaybackStates.containsKey(adsId)) { + // Currently playing a period without ads, or an ad period not managed by this ads loader. + cancelPendingAssetListResolutionMessage(); + return; + } + if ((reason == DISCONTINUITY_REASON_AUTO_TRANSITION || reason == DISCONTINUITY_REASON_SKIP) + && oldPosition.adGroupIndex != C.INDEX_UNSET) { + currentTimeline.getPeriod(oldPosition.periodIndex, period); + markAdAsPlayedAndNotifyListeners( + oldPosition.mediaItem, adsId, oldPosition.adGroupIndex, oldPosition.adIndexInAdGroup); + } else if (reason == DISCONTINUITY_REASON_SEEK + || reason == DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { + long windowPositionUs = msToUs(newPosition.contentPositionMs); + long assetListWindowPositionUs = + getUnresolvedAssetListWindowPositionForContentPositionUs( + windowPositionUs, currentTimeline, newPosition.periodIndex); + maybeExecuteOrSetNextAssetListResolutionMessage( + adsId, + currentTimeline, + newPosition.mediaItemIndex, + assetListWindowPositionUs != C.TIME_UNSET + ? assetListWindowPositionUs + : windowPositionUs); + } + } + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { Player player = HlsInterstitialsAdsLoader.this.player; if (playbackState != Player.STATE_ENDED || player == null || !player.isPlayingAd()) { return; @@ -938,7 +1187,9 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { private void markAdAsPlayedAndNotifyListeners( MediaItem mediaItem, Object adsId, int adGroupIndex, int adIndexInAdGroup) { @Nullable AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(adsId); - if (adPlaybackState != null) { + if (adPlaybackState != null + && adPlaybackState.getAdGroup(adGroupIndex).states[adIndexInAdGroup] + == AD_STATE_AVAILABLE) { adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup); putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); notifyListeners( @@ -946,4 +1197,205 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } } } + + private class LoaderCallback implements Loader.Callback> { + + private final AssetListData assetListData; + + /** Creates an instance. */ + public LoaderCallback(AssetListData assetListData) { + this.assetListData = assetListData; + } + + @Override + public void onLoadCompleted( + ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { + @Nullable AssetList assetList = loadable.getResult(); + AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(assetListData.adsId); + if (adPlaybackState == null || assetList == null || assetList.assets.isEmpty()) { + if (adPlaybackState != null) { + handleAssetResolutionFailed(new IOException("empty asset list"), /* cancelled= */ false); + } + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(assetListData.adGroupIndex); + long oldAdDurationUs = + adGroup.durationsUs[assetListData.adIndexInAdGroup] != C.TIME_UNSET + ? adGroup.durationsUs[assetListData.adIndexInAdGroup] + : 0; + int oldAdCount = adGroup.count; + long sumOfAssetListAdDurationUs = 0L; + if (assetList.assets.size() > 1) { + // expanding to multiple ads + adPlaybackState = + adPlaybackState.withAdCount( + assetListData.adGroupIndex, oldAdCount + assetList.assets.size() - 1); + // Re-fetch ad group after ad count changed + adGroup = adPlaybackState.getAdGroup(assetListData.adGroupIndex); + } + int adIndex = assetListData.adIndexInAdGroup; + long[] newDurationsUs = adGroup.durationsUs.clone(); + for (int i = 0; i < assetList.assets.size(); i++) { + Asset asset = assetList.assets.get(i); + if (i > 0) { + adIndex = oldAdCount + i - 1; + } + newDurationsUs[adIndex] = asset.durationUs; + sumOfAssetListAdDurationUs += asset.durationUs; + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(asset.uri) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(); + adPlaybackState = + adPlaybackState.withAvailableAdMediaItem( + assetListData.adGroupIndex, adIndex, mediaItem); + } + adPlaybackState = + adPlaybackState.withAdDurationsUs(assetListData.adGroupIndex, newDurationsUs); + if (assetListData.interstitial.resumeOffsetUs == C.TIME_UNSET) { + adGroup = adPlaybackState.getAdGroup(assetListData.adGroupIndex); + long newContentResumeOffsetUs = + adGroup.contentResumeOffsetUs - oldAdDurationUs + sumOfAssetListAdDurationUs; + adPlaybackState = + adPlaybackState.withContentResumeOffsetUs( + assetListData.adGroupIndex, newContentResumeOffsetUs); + } + putAndNotifyAdPlaybackStateUpdate(assetListData.adsId, adPlaybackState); + notifyListeners( + listener -> + listener.onAssetListLoadCompleted( + assetListData.mediaItem, + assetListData.adsId, + assetListData.adGroupIndex, + assetListData.adIndexInAdGroup, + assetList)); + maybeContinueAssetResolution(); + } + + @Override + public void onLoadCanceled( + ParsingLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + handleAssetResolutionFailed(/* error= */ null, /* cancelled= */ true); + } + + @Override + public Loader.LoadErrorAction onLoadError( + ParsingLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + handleAssetResolutionFailed(error, /* cancelled= */ false); + return Loader.DONT_RETRY; + } + + private void handleAssetResolutionFailed(@Nullable IOException error, boolean cancelled) { + notifyAssetResolutionFailed( + assetListData.adsId, assetListData.adGroupIndex, assetListData.adIndexInAdGroup); + notifyListeners( + listener -> + listener.onAssetListLoadFailed( + assetListData.mediaItem, + assetListData.adsId, + assetListData.adGroupIndex, + assetListData.adIndexInAdGroup, + error, + cancelled)); + maybeContinueAssetResolution(); + } + + private void maybeContinueAssetResolution() { + ExoPlayer player = HlsInterstitialsAdsLoader.this.player; + if (player == null + || player.getPlaybackState() == STATE_IDLE + || !assetListData.mediaItem.equals(player.getCurrentMediaItem())) { + return; + } + long contentPositionUs = msToUs(player.getContentPosition()); + Timeline currentTimeline = player.getCurrentTimeline(); + long assetListTimeUsForPositionUs = + getUnresolvedAssetListWindowPositionForContentPositionUs( + contentPositionUs, currentTimeline, player.getCurrentPeriodIndex()); + maybeExecuteOrSetNextAssetListResolutionMessage( + assetListData.adsId, + currentTimeline, + player.getCurrentMediaItemIndex(), + /* windowPositionUs= */ assetListTimeUsForPositionUs != C.TIME_UNSET + ? assetListTimeUsForPositionUs + : contentPositionUs); + } + } + + private static class RunnableAtPosition implements Runnable { + public final long adStartTimeUs; + private final long targetDurationUs; + private final Runnable runnable; + + /** Creates an instance. */ + public RunnableAtPosition(long adStartTimeUs, long targetDurationUs, Runnable runnable) { + this.adStartTimeUs = adStartTimeUs; + this.targetDurationUs = targetDurationUs; + this.runnable = runnable; + } + + @Override + public void run() { + runnable.run(); + } + } + + private static class AssetListData { + private final MediaItem mediaItem; + private final Object adsId; + private final int adGroupIndex; + private final int adIndexInAdGroup; + private final long targetDurationUs; + private final Interstitial interstitial; + + /** Create an instance. */ + public AssetListData( + MediaItem mediaItem, + Object adsId, + Interstitial interstitial, + int adGroupIndex, + int adIndexInAdGroup, + long targetDurationUs) { + checkArgument(interstitial.assetListUri != null); + this.mediaItem = mediaItem; + this.adsId = adsId; + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + this.targetDurationUs = targetDurationUs; + this.interstitial = interstitial; + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof AssetListData)) { + return false; + } + AssetListData that = (AssetListData) o; + return adGroupIndex == that.adGroupIndex + && adIndexInAdGroup == that.adIndexInAdGroup + && targetDurationUs == that.targetDurationUs + && Objects.equals(mediaItem, that.mediaItem) + && Objects.equals(adsId, that.adsId) + && Objects.equals(interstitial, that.interstitial); + } + + @Override + public int hashCode() { + int result = mediaItem.hashCode(); + result = 31 * result + adsId.hashCode(); + result = 31 * result + interstitial.hashCode(); + result = 31 * result + adGroupIndex; + result = 31 * result + adIndexInAdGroup; + result = (int) (31L * result + targetDurationUs); + return result; + } + } } 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 72d6922914..8993a044bf 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 @@ -16,11 +16,14 @@ package androidx.media3.exoplayer.hls; import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +import static androidx.media3.common.Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED; +import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; 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.ArgumentMatchers.isNotNull; import static org.mockito.Mockito.atMost; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -32,6 +35,8 @@ import static org.mockito.Mockito.when; import android.content.Context; import android.net.Uri; +import android.os.Looper; +import androidx.annotation.Nullable; import androidx.media3.common.AdPlaybackState; import androidx.media3.common.AdViewProvider; import androidx.media3.common.C; @@ -39,8 +44,16 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.Metadata; import androidx.media3.common.MimeTypes; import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.Clock; import androidx.media3.common.util.Util; +import androidx.media3.datasource.ByteArrayDataSource; import androidx.media3.datasource.DataSpec; +import androidx.media3.exoplayer.ExoPlaybackException; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.PlayerMessage; +import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.Asset; +import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; @@ -54,8 +67,12 @@ import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.Charset; import java.util.Arrays; import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -67,33 +84,61 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; /** Unit tests for {@link HlsInterstitialsAdsLoaderTest}. */ +@SuppressWarnings({"DataFlowIssue", "TextBlockMigration", "EnhancedSwitchMigration"}) @RunWith(AndroidJUnit4.class) public class HlsInterstitialsAdsLoaderTest { + private static final long TIMEOUT_MS = 1_000L; + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); - @Mock private AdsLoader.EventListener mockEventListener; @Mock private HlsInterstitialsAdsLoader.Listener mockAdsLoaderListener; + @Mock private AdsLoader.EventListener mockEventListener; + @Mock private ExoPlayer mockPlayer; @Mock private AdViewProvider mockAdViewProvider; - @Mock private Player mockPlayer; private HlsInterstitialsAdsLoader adsLoader; + private AssetListLoadingListener assetListLoadingListener; + private MediaItem.AdsConfiguration adsConfiguration; private MediaItem contentMediaItem; + private TimelineWindowDefinition contentWindowDefinition; private DataSpec adTagDataSpec; private AdsMediaSource adsMediaSource; - private TimelineWindowDefinition contentWindowDefinition; - private TimelineWindowDefinition adsMediaSourceWindowDefinition; @Before public void setUp() { - adsLoader = new HlsInterstitialsAdsLoader(); + adsLoader = + new HlsInterstitialsAdsLoader( + /* dataSourceFactory= */ () -> + new ByteArrayDataSource( + uri -> { + switch (uri.toString()) { + case "http://invalid": + return "]".getBytes(Charset.defaultCharset()); + case "http://empty": + return getJsonAssetList(/* assetCount= */ 0); + case "http://three-assets": + return getJsonAssetList(/* assetCount= */ 3); + default: + return getJsonAssetList(/* assetCount= */ 1); + } + })); adsLoader.addListener(mockAdsLoaderListener); - // The HLS URI to play + assetListLoadingListener = new AssetListLoadingListener(); + adsLoader.addListener(assetListLoadingListener); + adsConfiguration = new MediaItem.AdsConfiguration.Builder(Uri.EMPTY).setAdsId("adsId").build(); + // The HLS media item to play. contentMediaItem = new MediaItem.Builder() .setUri("http://example.com/media.m3u8") - .setAdsConfiguration( - new MediaItem.AdsConfiguration.Builder(Uri.EMPTY).setAdsId("adsId").build()) + .setAdsConfiguration(adsConfiguration) + .build(); + // The content timeline with AdPlaybackState.NONE. + contentWindowDefinition = + new TimelineWindowDefinition.Builder() + .setDurationUs(90_000_000L) + .setWindowPositionInFirstPeriodUs(0L) + .setMediaItem(contentMediaItem) .build(); adTagDataSpec = new DataSpec(Uri.EMPTY); // The ads media source using the ads loader. @@ -104,19 +149,6 @@ public class HlsInterstitialsAdsLoaderTest { mockAdViewProvider, (Context) ApplicationProvider.getApplicationContext()) .createMediaSource(contentMediaItem); - // The content timeline with empty ad playback state. - contentWindowDefinition = - new TimelineWindowDefinition.Builder() - .setDurationUs(90_000_000L) - .setWindowPositionInFirstPeriodUs(0L) - .setMediaItem(contentMediaItem) - .build(); - // The ads timeline with a minimal ad playback state with the ads ID. - adsMediaSourceWindowDefinition = - contentWindowDefinition - .buildUpon() - .setAdPlaybackStates(ImmutableList.of(new AdPlaybackState("adsId"))) - .build(); } @Test @@ -1222,7 +1254,8 @@ public class HlsInterstitialsAdsLoaderTest { new MediaItem.Builder() .setUri("http://example.com/media-1-0.m3u8") .setMimeType(MimeTypes.APPLICATION_M3U8) - .build())); + .build())) + .inOrder(); } @Test @@ -1307,12 +1340,937 @@ public class HlsInterstitialsAdsLoaderTest { .containsExactly( new AdPlaybackState("adsId") .withAdResumePositionUs(0) - .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)); + } + + @Test + public void handleContentTimelineChanged_preRollWithAssetList_resolveAssetListImmediately() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + AdPlaybackState expectedAdPlaybackStateAtTimelineChange = + new AdPlaybackState("adsId", 0L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0"); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + verify(mockAdsLoaderListener) + .onAssetListLoadCompleted( + contentMediaItem, + "adsId", + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new AssetList( + ImmutableList.of(new Asset(Uri.parse("http://0"), /* durationUs= */ 10_123_000L)), + ImmutableList.of())); + ArgumentCaptor adPlaybackStateCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(2)).onAdPlaybackState(adPlaybackStateCaptor.capture()); + assertThat(adPlaybackStateCaptor.getAllValues()) + .containsExactly( + expectedAdPlaybackStateAtTimelineChange, + expectedAdPlaybackStateAtTimelineChange + .withAdDurationsUs(/* adGroupIndex= */ 0, 10_123_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://0") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 10_123_000L)) + .inOrder(); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(any()); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void + handleContentTimelineChanged_preRollWithAssetList_resolvesAndSchedulesNextPlayerMessage() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:30.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + PlayerMessage midRollPlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(midRollPlayerMessage); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + verify(mockAdsLoaderListener) + .onAssetListLoadCompleted(eq(contentMediaItem), eq("adsId"), eq(0), eq(0), any()); + assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(12_000L); + assertThat(midRollPlayerMessage.getPayload()).isEqualTo(contentMediaItem); + assertThat(midRollPlayerMessage.getLooper()).isEqualTo(Looper.myLooper()); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(any()); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void handleContentTimelineChanged_assetListWithMultipleAssets_resolvesAndExpandsAdGroup() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://three-assets\"" + + "\n"; + + when(mockPlayer.getContentPosition()).thenReturn(0L); + AdPlaybackState expectedAdPlaybackStateAtTimelineChange = + new AdPlaybackState("adsId", 0L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0"); + + assertThat( + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0)) + .isEqualTo(expectedAdPlaybackStateAtTimelineChange); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + ArgumentCaptor adPlaybackStateCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(2)).onAdPlaybackState(adPlaybackStateCaptor.capture()); + assertThat(adPlaybackStateCaptor.getAllValues()) + .containsExactly( + expectedAdPlaybackStateAtTimelineChange, + expectedAdPlaybackStateAtTimelineChange + .withAdCount(/* adGroupIndex= */ 0, 3) + .withAdDurationsUs(/* adGroupIndex= */ 0, 10_123_000L, 11_123_000L, 12_123_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://0") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + new MediaItem.Builder() + .setUri("http://1") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 2, + new MediaItem.Builder() + .setUri("http://2") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withContentResumeOffsetUs( + /* adGroupIndex= */ 0, 10_123_000L + 11_123_000L + 12_123_000L)) + .inOrder(); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(any()); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void handleContentTimelineChanged_loadingAssetListFails_marksAdAndSchedulesNextMessage() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://invalid\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:25.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + PlayerMessage midRollPlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(midRollPlayerMessage); + AdPlaybackState expectedAdPlaybackStateAtTimelineChange = + new AdPlaybackState("adsId", 0L, 25_000_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdCount(/* adGroupIndex= */ 1, 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0"); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 1); + + runMainLooperUntil(assetListLoadingListener::failed, TIMEOUT_MS, Clock.DEFAULT); + ArgumentCaptor adPlaybackStateCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(2)).onAdPlaybackState(adPlaybackStateCaptor.capture()); + assertThat(adPlaybackStateCaptor.getAllValues()) + .containsExactly( + expectedAdPlaybackStateAtTimelineChange, + expectedAdPlaybackStateAtTimelineChange.withAdLoadError( + /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)) + .inOrder(); + assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(7_000L); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(any()); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void handleContentTimelineChanged_emptyAssetList_marksAdAsFailedAndSchedulesNextMessage() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:21.000Z\"," + + "X-ASSET-LIST=\"http://empty\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:51.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-2-0.json\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + PlayerMessage midRollPlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(midRollPlayerMessage); + AdPlaybackState expectedAdPlaybackStateAtTimelineChange = + new AdPlaybackState("adsId", 0L, 21_000_000L, 51_000_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdCount(/* adGroupIndex= */ 1, 1) + .withAdCount(/* adGroupIndex= */ 2, 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0") + .withAdId(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, "ad2-0"); + when(mockPlayer.getContentPosition()).thenReturn(21_000L); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 1); + + runMainLooperUntil(assetListLoadingListener::failed, TIMEOUT_MS, Clock.DEFAULT); + verify(mockAdsLoaderListener) + .onAssetListLoadFailed(any(), eq("adsId"), eq(1), eq(0), isNotNull(), eq(false)); + ArgumentCaptor adPlaybackStateCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(2)).onAdPlaybackState(adPlaybackStateCaptor.capture()); + assertThat(adPlaybackStateCaptor.getAllValues()) + .containsExactly( + expectedAdPlaybackStateAtTimelineChange, + expectedAdPlaybackStateAtTimelineChange.withAdLoadError( + /* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0)) + .inOrder(); + assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(33_000L); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(any()); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void + handleContentTimelineChanged_publicPlaceholderPeriod_useDefaultPositionToScheduleMessage() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:00.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "DURATION=3.246," + + "START-DATE=\"2020-01-02T21:00:30.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\"" + + "\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + contentWindowDefinition = + contentWindowDefinition + .buildUpon() + .setPlaceholder(true) + .setDynamic(true) + .setLive(true) + .setDefaultPositionUs(29_999_999L) + .build(); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + verify(mockAdsLoaderListener) + .onAssetListLoadCompleted(eq(contentMediaItem), eq("adsId"), eq(1), eq(0), isNotNull()); + verify(mockEventListener, times(2)).onAdPlaybackState(any()); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(any()); + // Timeline change immediately starts asset list resolution using the default position instead + // of the current content position. + inOrder.verify(mockPlayer).getCurrentMediaItem(); + inOrder.verify(mockPlayer).getCurrentPeriodIndex(); + inOrder.verify(mockPlayer).getCurrentTimeline(); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void + handleContentTimelineChanged_assetListAndAssetUriMixture_adPlaybackStateCorrectlyPopulated() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://three-assets\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "DURATION=3.246," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-1-0.ts\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + when(mockPlayer.createMessage(any())) + .thenReturn( + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null)); + AdPlaybackState expectedAdPlaybackStateAtTimelineChange = + new AdPlaybackState("adsId", 0L) + .withAdCount(/* adGroupIndex= */ 0, 2) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad1-0") + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, 3_246_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_246_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + new MediaItem.Builder() + .setUri("http://example.com/media-1-0.ts") + .setMimeType("application/x-mpegURL") + .build()); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + ArgumentCaptor adPlaybackStateCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(2)).onAdPlaybackState(adPlaybackStateCaptor.capture()); + assertThat(adPlaybackStateCaptor.getAllValues()) + .containsExactly( + expectedAdPlaybackStateAtTimelineChange, + expectedAdPlaybackStateAtTimelineChange + .withAdCount(/* adGroupIndex= */ 0, 4) + .withAdDurationsUs( + /* adGroupIndex= */ 0, 10_123_000L, 3_246_000L, 11_123_000L, 12_123_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://0") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 2, + new MediaItem.Builder() + .setUri("http://1") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 3, + new MediaItem.Builder() + .setUri("http://2") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withContentResumeOffsetUs( + /* adGroupIndex= */ 0, 3_246_000L + 10_123_000L + 11_123_000L + 12_123_000L)) .inOrder(); } @Test - public void onPositionDiscontinuity_marksAdAsPlayed() throws IOException { + public void timelineChange_mediaItemsClearedWithoutPositionDiscontinuity_cancelsPendingMessage() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:21.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + PlayerMessage playerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(playerMessage); + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(listener.capture()); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + + listener.getValue().onTimelineChanged(Timeline.EMPTY, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + + assertThat(playerMessage.isCanceled()).isTrue(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void handleMessage_playerMessageExecuted_resolvesAssetListAndSchedulesNextMessage() + throws IOException, TimeoutException, ExoPlaybackException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:30.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:54.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\"" + + "\n"; + PlayerMessage midRoll2PlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.getContentPosition()).thenReturn(0L); + when(mockPlayer.createMessage(any())) + .thenReturn( + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null)); + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 2); + when(mockPlayer.getCurrentPeriodIndex()).thenReturn(2); + when(mockPlayer.getCurrentMediaItemIndex()).thenReturn(2); + InOrder inOrder = inOrder(mockPlayer); + // Timeline change schedules asset list resolution. + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + ArgumentCaptor targetCaptor = + ArgumentCaptor.forClass(PlayerMessage.Target.class); + inOrder.verify(mockPlayer).createMessage(targetCaptor.capture()); + when(mockPlayer.createMessage(any())).thenReturn(midRoll2PlayerMessage); + + targetCaptor.getValue().handleMessage(/* ignored */ -1, contentMediaItem); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + assertThat(midRoll2PlayerMessage.isCanceled()).isFalse(); + assertThat(midRoll2PlayerMessage.getPositionMs()).isEqualTo(36_000L); + assertThat(midRoll2PlayerMessage.getPayload()).isEqualTo(contentMediaItem); + assertThat(midRoll2PlayerMessage.getLooper()).isEqualTo(Looper.myLooper()); + } + + @Test + public void positionDiscontinuity_reasonSeek_immediatelyResolvesAndSchedulesNextMessages() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T22:00:00.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:30.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:54.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-2-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad3-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T20:00:00.000Z\"," + + "CUE=\"POST\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-3-0.json\"" + + "\n"; + Object windowUid = new Object(); + Object periodUid = new Object(); + PlayerMessage midRoll1PlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + PlayerMessage midRoll2PlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + PlayerMessage postRollPlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + Player.PositionInfo positionZero = + new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 1, + contentMediaItem, + periodUid, + /* periodIndex= */ 1, + /* positionMs= */ 0L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1); + when(mockPlayer.getContentPosition()).thenReturn(0L); + when(mockPlayer.createMessage(any())).thenReturn(midRoll1PlayerMessage); + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 1); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(listener.capture()); + // This call must be a no-op because the loader is loading. + listener + .getValue() + .onPositionDiscontinuity( + /* oldPosition= */ positionZero, + /* newPosition= */ new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 1, + contentMediaItem, + periodUid, + /* periodIndex= */ 1, + /* positionMs= */ 30_001L, + /* contentPositionMs= */ 30_001L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + Player.DISCONTINUITY_REASON_SEEK); + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + when(mockPlayer.createMessage(any())).thenReturn(midRoll2PlayerMessage); + + // A seek beyond the ad group at 30_000_000 with an unresolved asset list. The asset list for + // 30_000_000 is immediately loaded and the player message for the next mid roll is scheduled. + listener + .getValue() + .onPositionDiscontinuity( + /* oldPosition= */ positionZero, + /* newPosition= */ new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 1, + contentMediaItem, + periodUid, + /* periodIndex= */ 1, + /* positionMs= */ 30_001L, + /* contentPositionMs= */ 30_001L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + Player.DISCONTINUITY_REASON_SEEK); + + runMainLooperUntil( + () -> assetListLoadingListener.assetListLoadCompletedCounter.get() == 2, + TIMEOUT_MS, + Clock.DEFAULT); + when(mockPlayer.createMessage(any())).thenReturn(postRollPlayerMessage); + // A seek beyond the ad group at 54_000_000 with an unresolved asset list. The asset list for + // 54_000_000 is immediately loaded and the player message for the post roll is scheduled. + listener + .getValue() + .onPositionDiscontinuity( + /* oldPosition= */ positionZero, + /* newPosition= */ new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 1, + contentMediaItem, + periodUid, + /* periodIndex= */ 1, + /* positionMs= */ 54_001L, + /* contentPositionMs= */ 54_001L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + Player.DISCONTINUITY_REASON_SEEK); + + runMainLooperUntil( + () -> assetListLoadingListener.assetListLoadCompletedCounter.get() == 3, + TIMEOUT_MS, + Clock.DEFAULT); + assertThat(midRoll1PlayerMessage.isCanceled()).isTrue(); + assertThat(midRoll1PlayerMessage.getPositionMs()).isEqualTo(12_000L); + assertThat(midRoll1PlayerMessage.getPayload()).isEqualTo(contentMediaItem); + assertThat(midRoll1PlayerMessage.getLooper()).isEqualTo(Looper.myLooper()); + assertThat(midRoll2PlayerMessage.isCanceled()).isTrue(); + assertThat(midRoll2PlayerMessage.getPositionMs()).isEqualTo(36_000L); + assertThat(midRoll2PlayerMessage.getPayload()).isEqualTo(contentMediaItem); + assertThat(midRoll1PlayerMessage.getLooper()).isEqualTo(Looper.myLooper()); + assertThat(postRollPlayerMessage.isCanceled()).isFalse(); + assertThat(postRollPlayerMessage.getPositionMs()).isEqualTo(72_000L); + assertThat(postRollPlayerMessage.getPayload()).isEqualTo(contentMediaItem); + assertThat(midRoll1PlayerMessage.getLooper()).isEqualTo(Looper.myLooper()); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(AssetList.class); + verify(mockAdsLoaderListener, times(3)) + .onAssetListLoadCompleted( + eq(contentMediaItem), eq("adsId"), anyInt(), anyInt(), argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()) + .containsExactly( + new AssetList( + ImmutableList.of(new Asset(Uri.parse("http://0"), 10_123_000L)), + ImmutableList.of()), + new AssetList( + ImmutableList.of(new Asset(Uri.parse("http://0"), 10_123_000L)), + ImmutableList.of()), + new AssetList( + ImmutableList.of(new Asset(Uri.parse("http://0"), 10_123_000L)), + ImmutableList.of())) + .inOrder(); + // Timeline change immediately starts asset list resolution. + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ false); + // Position discontinuity during asset list loading. + inOrder.verify(mockPlayer).getCurrentTimeline(); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + // Position discontinuity immediately starts asset list resolution. + inOrder.verify(mockPlayer).getCurrentTimeline(); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + // Position discontinuity immediately starts asset list resolution. + inOrder.verify(mockPlayer).getCurrentTimeline(); + verifyAssetListLoadCompleted(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void positionDiscontinuity_reasonSeek_adPlaybackStatePopulatedCorrectly() + throws IOException, TimeoutException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:21.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:51.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\"" + + "\n"; + Object windowUid = new Object(); + Object periodUid = new Object(); + when(mockPlayer.getContentPosition()).thenReturn(0L); + AdPlaybackState expectedAdPlaybackStateAtTimelineChange = + new AdPlaybackState("adsId", 21_000_000L, 51_000_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdCount(/* adGroupIndex= */ 1, 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0"); + PlayerMessage midRoll1PlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(midRoll1PlayerMessage); + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 0); + // Emulate position discontinuity. + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + verify(mockPlayer).addListener(listener.capture()); + PlayerMessage midRoll2PlayerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(midRoll2PlayerMessage); + + listener + .getValue() + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 0, + contentMediaItem, + periodUid, + /* periodIndex= */ 0, + /* positionMs= */ 0L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + /* newPosition= */ new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 0, + contentMediaItem, + periodUid, + /* periodIndex= */ 0, + /* positionMs= */ 20_000L, + /* contentPositionMs= */ 20_000L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + Player.DISCONTINUITY_REASON_SEEK); + + runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT); + assertThat(midRoll1PlayerMessage.isCanceled()).isTrue(); + assertThat(midRoll1PlayerMessage.getPositionMs()).isEqualTo(3_000L); + assertThat(midRoll1PlayerMessage.getPayload()).isEqualTo(contentMediaItem); + assertThat(midRoll2PlayerMessage.isCanceled()).isFalse(); + assertThat(midRoll2PlayerMessage.getPositionMs()).isEqualTo(33_000L); + assertThat(midRoll2PlayerMessage.getPayload()).isEqualTo(contentMediaItem); + ArgumentCaptor adPlaybackStateCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(2)).onAdPlaybackState(adPlaybackStateCaptor.capture()); + assertThat(adPlaybackStateCaptor.getAllValues()) + .containsExactly( + expectedAdPlaybackStateAtTimelineChange, + expectedAdPlaybackStateAtTimelineChange + .withAdDurationsUs(/* adGroupIndex= */ 0, 10_123_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://0") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build()) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 10_123_000L)) + .inOrder(); + } + + @Test + public void + positionDiscontinuity_reasonSeekToMediaItemWithoutAd_cancelsPendingAssetListResolutionMessage() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:9\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:9,\n" + + "main0.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:00:21.000Z\"," + + "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\"" + + "\n"; + when(mockPlayer.getContentPosition()).thenReturn(0L); + PlayerMessage playerMessage = + new PlayerMessage( + mock(PlayerMessage.Sender.class), + mock(PlayerMessage.Target.class), + Timeline.EMPTY, + /* defaultMediaItemIndex= */ 0, + /* Clock ignored */ null, + /* Looper ignored */ null); + when(mockPlayer.createMessage(any())).thenReturn(playerMessage); + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + playlistString, adsLoader, /* windowIndex= */ 1); + // Emulate position discontinuity to a non-ad media item. + MediaItem nonAdMediaItem = MediaItem.fromUri(Uri.parse("http://example.com/no-ad")); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + InOrder inOrder = inOrder(mockPlayer); + inOrder.verify(mockPlayer).addListener(listener.capture()); + + listener + .getValue() + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + /* windowUid= */ new Object(), + /* mediaItemIndex= */ 1, + contentMediaItem, + /* periodUid= */ new Object(), + /* periodIndex= */ 1, + /* positionMs= */ 0L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + /* newPosition= */ new Player.PositionInfo( + /* windowUid= */ new Object(), + /* mediaItemIndex= */ 0, + nonAdMediaItem, + /* periodUid= */ new Object(), + /* periodIndex= */ 0, + /* positionMs= */ 20_000L, + /* contentPositionMs= */ 20_000L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + Player.DISCONTINUITY_REASON_SEEK); + + assertThat(playerMessage.isCanceled()).isTrue(); + verifyTimelineUpdate(inOrder, mockPlayer, /* verifyMessageScheduled= */ true); + // Position discontinuity to next media item cancels pending message. + inOrder.verify(mockPlayer).getCurrentTimeline(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void positionDiscontinuity_reasonAutoTransition_marksAdAsPlayed() throws IOException { String playlistString = "#EXTM3U\n" + "#EXT-X-TARGETDURATION:6\n" @@ -1525,7 +2483,13 @@ public class HlsInterstitialsAdsLoaderTest { when(mockPlayer.getCurrentAdGroupIndex()).thenReturn(1); when(mockPlayer.getCurrentAdIndexInAdGroup()).thenReturn(2); when(mockPlayer.getCurrentTimeline()) - .thenReturn(new FakeTimeline(adsMediaSourceWindowDefinition)); + .thenReturn( + new FakeTimeline( + contentWindowDefinition + .buildUpon() + .setAdPlaybackStates( + ImmutableList.of(new AdPlaybackState(adsConfiguration.adsId))) + .build())); adsLoader.setPlayer(mockPlayer); adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); @@ -1742,12 +2706,12 @@ public class HlsInterstitialsAdsLoaderTest { adsLoader.setPlayer(mockPlayer); adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); - assertThrows(IllegalStateException.class, () -> adsLoader.setPlayer(secondMockPlayer)); + assertThrows(IllegalArgumentException.class, () -> adsLoader.setPlayer(secondMockPlayer)); } @Test public void setPlayer_playerAlreadySetWithoutActiveListeners_playerSet() { - Player secondMockPlayer = mock(Player.class); + Player secondMockPlayer = mock(ExoPlayer.class); when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); adsLoader.setPlayer(mockPlayer); @@ -1987,7 +2951,7 @@ public class HlsInterstitialsAdsLoaderTest { .setMediaItem(MediaItem.fromUri("http://example.com/")) .build()); windowsAfterTimelineChange[windowIndex] = - adsMediaSourceWindowDefinition + contentWindowDefinition .buildUpon() .setAdPlaybackStates(ImmutableList.of(adPlaybackState.getValue())) .build(); @@ -1996,4 +2960,82 @@ public class HlsInterstitialsAdsLoaderTest { when(mockPlayer.getCurrentPeriodIndex()).thenReturn(windowIndex); return adPlaybackState.getValue(); } + + private static void verifyTimelineUpdate( + InOrder inOrder, ExoPlayer mockPlayer, boolean verifyMessageScheduled) { + inOrder.verify(mockPlayer).getCurrentMediaItem(); + inOrder.verify(mockPlayer).getContentPosition(); + if (verifyMessageScheduled) { + inOrder.verify(mockPlayer).createMessage(any()); + } + } + + private static void verifyAssetListLoadCompleted( + InOrder inOrder, ExoPlayer mockPlayer, boolean verifyMessageScheduled) { + inOrder.verify(mockPlayer).getCurrentMediaItem(); + inOrder.verify(mockPlayer).getContentPosition(); + inOrder.verify(mockPlayer).getCurrentTimeline(); + inOrder.verify(mockPlayer).getCurrentPeriodIndex(); + inOrder.verify(mockPlayer).getCurrentMediaItemIndex(); + if (verifyMessageScheduled) { + inOrder.verify(mockPlayer).createMessage(any()); + } + } + + private byte[] getJsonAssetList(int assetCount) { + StringBuilder assetList = new StringBuilder("{\"ASSETS\": ["); + for (int i = 0; i < assetCount; i++) { + assetList.append(getJsonAsset(/* uri= */ "http://" + i, /* durationSec= */ 10.123d + i)); + if (i < assetCount - 1) { + assetList.append(","); + } + } + return assetList.append("]}\n").toString().getBytes(Charset.defaultCharset()); + } + + private static String getJsonAsset(String uri, double durationSec) { + return String.format(Locale.US, "{\"URI\": \"%s\", \"DURATION\": %f}", uri, durationSec); + } + + @SuppressWarnings("NewClassNamingConvention") + private static final class AssetListLoadingListener + implements HlsInterstitialsAdsLoader.Listener { + + private final AtomicInteger assetListLoadCompletedCounter; + private final AtomicInteger assetListLoadFailedCounter; + + private AssetListLoadingListener() { + this.assetListLoadCompletedCounter = new AtomicInteger(); + this.assetListLoadFailedCounter = new AtomicInteger(); + } + + public boolean completed() { + return assetListLoadCompletedCounter.get() > 0; + } + + public boolean failed() { + return assetListLoadFailedCounter.get() > 0; + } + + @Override + public void onAssetListLoadCompleted( + MediaItem mediaItem, + Object adsId, + int adGroupIndex, + int adIndexInAdGroup, + AssetList assetList) { + assetListLoadCompletedCounter.incrementAndGet(); + } + + @Override + public void onAssetListLoadFailed( + MediaItem mediaItem, + Object adsId, + int adGroupIndex, + int adIndexInAdGroup, + @Nullable IOException ioException, + boolean cancelled) { + assetListLoadFailedCounter.incrementAndGet(); + } + } }