diff --git a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java index b7251d9b9d..0f4b45de67 100644 --- a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java +++ b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java @@ -844,6 +844,13 @@ public final class AdPlaybackState { adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount); } + /** Returns an instance with the specified value for {@link #adsId}. */ + @CheckResult + public AdPlaybackState withAdsId(Object adsId) { + return new AdPlaybackState( + adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount); + } + /** * Returns an instance with the specified ad marked as {@linkplain #AD_STATE_AVAILABLE available}. * diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java index 776f27caec..73281d2218 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java @@ -168,11 +168,11 @@ public interface AdsLoader { * AdViewProvider, boolean) AdsMediaSource} to indicate the content source needs to be prepared * upfront. * - * @param mediaItem The {@link MediaItem} of the source that produced the timeline. + * @param adsMediaSource The ads media source for which the content timeline changed. * @param timeline The timeline of the content source. */ @UnstableApi - default void handleContentTimelineChanged(MediaItem mediaItem, Timeline timeline) { + default void handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) { // Do nothing. } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java index 23ead8fffb..f9b5c279f2 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java @@ -201,8 +201,8 @@ public final class AdsMediaSource extends CompositeMediaSource { * @param adViewProvider Provider of views for the ad UI. * @param useLazyContentSourcePreparation True if the content source should be prepared lazily and * wait for an {@link AdPlaybackState} to be set before preparing. False if the timeline is - * required {@linkplain AdsLoader#handleContentTimelineChanged(MediaItem, Timeline) to read ad - * data from it} to populate the {@link AdPlaybackState} (for instance from HLS + * required {@linkplain AdsLoader#handleContentTimelineChanged(AdsMediaSource, Timeline) to + * read ad data from it} to populate the {@link AdPlaybackState} (for instance from HLS * interstitials). */ public AdsMediaSource( @@ -234,6 +234,11 @@ public final class AdsMediaSource extends CompositeMediaSource { return contentMediaSource.getMediaItem(); } + /** Returns the ads ID this source is serving. */ + public Object getAdsId() { + return adsId; + } + @Override public boolean canUpdateMediaItem(MediaItem mediaItem) { return Util.areEqual(getAdsConfiguration(getMediaItem()), getAdsConfiguration(mediaItem)) @@ -330,7 +335,7 @@ public final class AdsMediaSource extends CompositeMediaSource { } else { Assertions.checkArgument(newTimeline.getPeriodCount() == 1); contentTimeline = newTimeline; - mainHandler.post(() -> adsLoader.handleContentTimelineChanged(getMediaItem(), newTimeline)); + mainHandler.post(() -> adsLoader.handleContentTimelineChanged(this, newTimeline)); } maybeUpdateSourceInfo(); } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java index 590951a838..e042df6e60 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java @@ -387,7 +387,8 @@ public final class AdsMediaSourceTest { } @Override - public void handleContentTimelineChanged(MediaItem mediaItem, Timeline timeline) { + public void handleContentTimelineChanged( + AdsMediaSource adsMediaSource, Timeline timeline) { contentTimelineChangedCalledLatch.countDown(); } }; @@ -563,7 +564,8 @@ public final class AdsMediaSourceTest { } @Override - public void handleContentTimelineChanged(MediaItem mediaItem, Timeline timeline) { + public void handleContentTimelineChanged( + AdsMediaSource adsMediaSource, Timeline timeline) { contentTimelineChangedCallCount.incrementAndGet(); } }; @@ -724,7 +726,7 @@ public final class AdsMediaSourceTest { IOException exception) {} @Override - public void handleContentTimelineChanged(MediaItem mediaItem, Timeline timeline) {} + public void handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) {} } private static MediaSource buildMediaSource(MediaItem mediaItem) { 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 new file mode 100644 index 0000000000..78c05c1186 --- /dev/null +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java @@ -0,0 +1,559 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.hls; + +import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +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 java.lang.Math.max; + +import androidx.annotation.Nullable; +import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.AdViewProvider; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaItem.LocalConfiguration; +import androidx.media3.common.Metadata; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.Timeline.Period; +import androidx.media3.common.Timeline.Window; +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.DataSpec; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial; +import androidx.media3.exoplayer.source.ads.AdsLoader; +import androidx.media3.exoplayer.source.ads.AdsMediaSource; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * An {@linkplain AdsLoader ads loader} that reads interstitials from the HLS playlist, adds them to + * the {@link AdPlaybackState} and passes the ad playback state to {@link + * EventListener#onAdPlaybackState(AdPlaybackState)}. + * + *

An ads ID must be unique within the playlist of ExoPlayer. If this is the case, a single + * {@link HlsInterstitialsAdsLoader} instance can be passed to multiple {@linkplain AdsMediaSource + * ads media sources}. These ad media source can be added to the same playlist as far as each of the + * sources have a different ads IDs. + */ +@UnstableApi +public final class HlsInterstitialsAdsLoader implements AdsLoader { + + /** A listener to be notified of events emitted by the ads loader. */ + public interface Listener { + + /** + * Called when the ads loader was started for the given HLS media item and ads ID. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param adViewProvider {@linkplain AdViewProvider Provider} of views for the ad UI. + */ + default void onStart(MediaItem mediaItem, Object adsId, AdViewProvider adViewProvider) { + // Do nothing. + } + + /** + * Called when the timeline of the content media source has changed. The {@link HlsManifest} of + * the content source can be accessed through {@link Window#manifest}. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param hlsContentTimeline The latest {@link Timeline}. + */ + default void onContentTimelineChanged( + MediaItem mediaItem, Object adsId, Timeline hlsContentTimeline) { + // Do nothing. + } + + /** + * Called when preparation of an ad period has completed successfully. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param adGroupIndex The index of the ad group in the ad media source. + * @param adIndexInAdGroup The index of the ad in the ad group. + */ + default void onPrepareCompleted( + MediaItem mediaItem, Object adsId, int adGroupIndex, int adIndexInAdGroup) { + // Do nothing. + } + + /** + * Called when preparation of an ad period failed. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param adGroupIndex The index of the ad group in the ad media source. + * @param adIndexInAdGroup The index of the ad in the ad group. + * @param exception The {@link IOException} thrown when preparing. + */ + default void onPrepareError( + MediaItem mediaItem, + Object adsId, + int adGroupIndex, + int adIndexInAdGroup, + IOException exception) { + // Do nothing. + } + + /** + * Called when {@link Metadata} is emitted by the player during an ad period of an active HLS + * media item. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param adGroupIndex The index of the ad group in the ad media source. + * @param adIndexInAdGroup The index of the ad in the ad group. + * @param metadata The emitted {@link Metadata}. + */ + default void onMetadata( + MediaItem mediaItem, + Object adsId, + int adGroupIndex, + int adIndexInAdGroup, + Metadata metadata) { + // Do nothing. + } + + /** + * Called when an ad period has completed playback and transitioned to the following ad or + * content period, or the playlist ended. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param adGroupIndex The index of the ad group in the ad media source. + * @param adIndexInAdGroup The index of the ad in the ad group. + */ + default void onAdCompleted( + MediaItem mediaItem, Object adsId, int adGroupIndex, int adIndexInAdGroup) { + // Do nothing. + } + + /** + * Called when the ads loader was stopped for the given HLS media item. + * + * @param mediaItem The {@link MediaItem} of the content media source. + * @param adsId The ads ID of the ads media source. + * @param adPlaybackState The {@link AdPlaybackState} after the ad media source was released. + */ + default void onStop(MediaItem mediaItem, Object adsId, AdPlaybackState adPlaybackState) { + // Do nothing. + } + } + + private static final String TAG = "HlsInterstitiaAdsLoader"; + + private final PlayerListener playerListener; + private final Map activeEventListeners; + private final Map activeAdPlaybackStates; + private final List listeners; + private final Set unsupportedAdsIds; + + @Nullable private Player player; + private boolean isReleased; + + /** Creates an instance. */ + public HlsInterstitialsAdsLoader() { + playerListener = new PlayerListener(); + activeEventListeners = new HashMap<>(); + activeAdPlaybackStates = new HashMap<>(); + listeners = new ArrayList<>(); + unsupportedAdsIds = new HashSet<>(); + } + + /** Adds a {@link Listener}. */ + public void addListener(Listener listener) { + listeners.add(listener); + } + + /** Removes a {@link Listener}. */ + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + // Implementation of AdsLoader methods + + /** + * {@inheritDoc} + * + * @throws IllegalStateException If an app is attempting to set a new player instance after {@link + * #release} was called or while {@linkplain AdsMediaSource ads media sources} started by the + * old player are still active, an {@link IllegalStateException} is thrown. Release the old + * player first, or remove all ads media sources from it before setting another player + * instance. + */ + @Override + public void setPlayer(@Nullable Player player) { + checkState(!isReleased); + if (Objects.equals(this.player, player)) { + return; + } + if (this.player != null && !activeEventListeners.isEmpty()) { + this.player.removeListener(playerListener); + } + checkState(player == null || activeEventListeners.isEmpty()); + this.player = player; + } + + @Override + public void setSupportedContentTypes(@C.ContentType int... contentTypes) { + for (int contentType : contentTypes) { + if (contentType == C.CONTENT_TYPE_HLS) { + return; + } + } + throw new IllegalArgumentException(); + } + + @Override + public void start( + AdsMediaSource adsMediaSource, + DataSpec adTagDataSpec, + Object adsId, + AdViewProvider adViewProvider, + EventListener eventListener) { + if (isReleased) { + // Run without ads after release to not interrupt playback. + eventListener.onAdPlaybackState(new AdPlaybackState(adsId)); + return; + } + if (activeAdPlaybackStates.containsKey(adsId) || unsupportedAdsIds.contains(adsId)) { + throw new IllegalStateException( + "media item with adsId='" + + adsId + + "' already started. Make sure adsIds are unique within the same playlist."); + } + if (activeEventListeners.isEmpty()) { + // Set the player listener when the first ad starts. + checkStateNotNull(player, "setPlayer(Player) needs to be called").addListener(playerListener); + } + activeEventListeners.put(adsId, eventListener); + MediaItem mediaItem = adsMediaSource.getMediaItem(); + if (player != null && isSupportedMediaItem(mediaItem, player.getCurrentTimeline())) { + // Mark with NONE. Update and notify later when timeline with interstitials arrives. + activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE); + 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); + } + } + } + + @Override + public void handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) { + Object adsId = adsMediaSource.getAdsId(); + if (isReleased) { + EventListener eventListener = activeEventListeners.remove(adsId); + if (eventListener != null) { + unsupportedAdsIds.remove(adsId); + AdPlaybackState adPlaybackState = checkNotNull(activeAdPlaybackStates.remove(adsId)); + if (adPlaybackState.equals(AdPlaybackState.NONE)) { + // Play without ads after release to not interrupt playback. + eventListener.onAdPlaybackState(new AdPlaybackState(adsId)); + } + } + return; + } + AdPlaybackState adPlaybackState = checkNotNull(activeAdPlaybackStates.get(adsId)); + if (!adPlaybackState.equals(AdPlaybackState.NONE)) { + // VOD only. Updating the playback state is not supported yet. + return; + } + adPlaybackState = new AdPlaybackState(adsId); + Window window = timeline.getWindow(0, new Window()); + if (window.manifest instanceof HlsManifest) { + adPlaybackState = + mapHlsInterstitialsToAdPlaybackState( + ((HlsManifest) window.manifest).mediaPlaylist, adPlaybackState); + } + putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); + if (!unsupportedAdsIds.contains(adsId)) { + notifyListeners( + listener -> + listener.onContentTimelineChanged(adsMediaSource.getMediaItem(), adsId, timeline)); + } + } + + @Override + public void handlePrepareComplete( + AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup) { + Object adsId = adsMediaSource.getAdsId(); + if (!isReleased && !unsupportedAdsIds.contains(adsId)) { + notifyListeners( + listener -> + listener.onPrepareCompleted( + adsMediaSource.getMediaItem(), adsId, adGroupIndex, adIndexInAdGroup)); + } + } + + @Override + public void handlePrepareError( + AdsMediaSource adsMediaSource, + int adGroupIndex, + int adIndexInAdGroup, + IOException exception) { + Object adsId = adsMediaSource.getAdsId(); + AdPlaybackState adPlaybackState = + checkNotNull(activeAdPlaybackStates.get(adsId)) + .withAdLoadError(adGroupIndex, adIndexInAdGroup); + putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); + if (!isReleased && !unsupportedAdsIds.contains(adsId)) { + notifyListeners( + listener -> + listener.onPrepareError( + adsMediaSource.getMediaItem(), adsId, adGroupIndex, adIndexInAdGroup, exception)); + } + } + + @Override + public void stop(AdsMediaSource adsMediaSource, EventListener eventListener) { + Object adsId = adsMediaSource.getAdsId(); + activeEventListeners.remove(adsId); + @Nullable AdPlaybackState adPlaybackState = activeAdPlaybackStates.remove(adsId); + if (player != null && activeEventListeners.isEmpty()) { + player.removeListener(playerListener); + if (isReleased) { + player = null; + } + } + if (!isReleased && !unsupportedAdsIds.contains(adsId)) { + notifyListeners( + listener -> + listener.onStop( + adsMediaSource.getMediaItem(), + adsMediaSource.getAdsId(), + checkNotNull(adPlaybackState))); + } + unsupportedAdsIds.remove(adsId); + } + + @Override + public void release() { + // Note: Do not clear active resources as media sources still may have references to the loader + // and we need to ensure sources can complete playback. + if (activeEventListeners.isEmpty()) { + player = null; + } + isReleased = true; + } + + // private methods + + private void putAndNotifyAdPlaybackStateUpdate(Object adsId, AdPlaybackState adPlaybackState) { + @Nullable + AdPlaybackState oldAdPlaybackState = activeAdPlaybackStates.put(adsId, adPlaybackState); + if (!adPlaybackState.equals(oldAdPlaybackState)) { + @Nullable EventListener eventListener = activeEventListeners.get(adsId); + if (eventListener != null) { + eventListener.onAdPlaybackState(adPlaybackState); + } else { + activeAdPlaybackStates.remove(adsId); + } + } + } + + private void notifyListeners(Consumer callable) { + for (int i = 0; i < listeners.size(); i++) { + callable.accept(listeners.get(i)); + } + } + + 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(); + while (windowIndex != C.INDEX_UNSET) { + timeline.getWindow(windowIndex, window); + if (window.mediaItem.equals(mediaItem)) { + return window.isLive(); + } + windowIndex = + timeline.getNextWindowIndex( + windowIndex, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ false); + } + return false; + } + + private static boolean isHlsMediaItem(MediaItem mediaItem) { + LocalConfiguration localConfiguration = checkNotNull(mediaItem.localConfiguration); + return Objects.equals(localConfiguration.mimeType, MimeTypes.APPLICATION_M3U8) + || 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); + 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; + } else { + positionUs = interstitial.startDateUnixUs - hlsMediaPlaylist.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); + 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) { + // There is an ad group before the interstitials. Insert after that index. + adGroupIndex++; + adPlaybackState = adPlaybackState.withNewAdGroup(adGroupIndex, positionUs); + } + + 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)); + } + return adPlaybackState; + } + + private static long getInterstitialDurationUs(Interstitial interstitial, long defaultDurationUs) { + if (interstitial.playoutLimitUs != C.TIME_UNSET) { + return interstitial.playoutLimitUs; + } else if (interstitial.durationUs != C.TIME_UNSET) { + return interstitial.durationUs; + } else if (interstitial.endDateUnixUs != C.TIME_UNSET) { + return interstitial.endDateUnixUs - interstitial.startDateUnixUs; + } else if (interstitial.plannedDurationUs != C.TIME_UNSET) { + return interstitial.plannedDurationUs; + } + return defaultDurationUs; + } + + private class PlayerListener implements Player.Listener { + + private final Period period = new Period(); + + @Override + public void onMetadata(Metadata metadata) { + @Nullable Player player = HlsInterstitialsAdsLoader.this.player; + if (player == null || !player.isPlayingAd()) { + return; + } + player.getCurrentTimeline().getPeriod(player.getCurrentPeriodIndex(), period); + @Nullable Object adsId = period.adPlaybackState.adsId; + if (adsId == null || !activeAdPlaybackStates.containsKey(adsId)) { + return; + } + MediaItem currentMediaItem = checkNotNull(player.getCurrentMediaItem()); + int currentAdGroupIndex = player.getCurrentAdGroupIndex(); + int currentAdIndexInAdGroup = player.getCurrentAdIndexInAdGroup(); + notifyListeners( + listener -> + listener.onMetadata( + currentMediaItem, adsId, currentAdGroupIndex, currentAdIndexInAdGroup, metadata)); + } + + @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); + } + } + + @Override + public void onPlaybackStateChanged(int playbackState) { + Player player = HlsInterstitialsAdsLoader.this.player; + if (playbackState != Player.STATE_ENDED || player == null || !player.isPlayingAd()) { + return; + } + player.getCurrentTimeline().getPeriod(player.getCurrentPeriodIndex(), period); + @Nullable Object adsId = period.adPlaybackState.adsId; + if (adsId != null && activeAdPlaybackStates.containsKey(adsId)) { + markAdAsPlayedAndNotifyListeners( + checkNotNull(player.getCurrentMediaItem()), + adsId, + player.getCurrentAdGroupIndex(), + player.getCurrentAdIndexInAdGroup()); + } + } + + private void markAdAsPlayedAndNotifyListeners( + MediaItem mediaItem, Object adsId, int adGroupIndex, int adIndexInAdGroup) { + @Nullable AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(adsId); + if (adPlaybackState != null) { + adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup); + putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState); + notifyListeners( + listener -> listener.onAdCompleted(mediaItem, adsId, adGroupIndex, adIndexInAdGroup)); + } + } + } +} 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 new file mode 100644 index 0000000000..8d2b1c269b --- /dev/null +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoaderTest.java @@ -0,0 +1,1398 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.hls; + +import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +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.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.net.Uri; +import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.AdViewProvider; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Metadata; +import androidx.media3.common.Player; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSpec; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser; +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.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 org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link HlsInterstitialsAdsLoaderTest}. */ +@RunWith(AndroidJUnit4.class) +public class HlsInterstitialsAdsLoaderTest { + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private AdsLoader.EventListener mockEventListener; + @Mock private HlsInterstitialsAdsLoader.Listener mockAdsLoaderListener; + @Mock private AdViewProvider mockAdViewProvider; + @Mock private Player mockPlayer; + + private HlsInterstitialsAdsLoader adsLoader; + private MediaItem contentMediaItem; + private DataSpec adTagDataSpec; + private AdsMediaSource adsMediaSource; + private FakeTimeline.TimelineWindowDefinition contentWindowDefinition; + private FakeTimeline.TimelineWindowDefinition adsMediaSourceWindowDefinition; + + @Before + public void setUp() { + adsLoader = new HlsInterstitialsAdsLoader(); + adsLoader.addListener(mockAdsLoaderListener); + // The HLS URI to play + contentMediaItem = + new MediaItem.Builder() + .setUri("http://example.com/media.m3u8") + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.EMPTY).setAdsId("adsId").build()) + .build(); + adTagDataSpec = new DataSpec(contentMediaItem.localConfiguration.adsConfiguration.adTagUri); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + // The ads media source using the ads loader. + adsMediaSource = + new AdsMediaSource( + defaultMediaSourceFactory.createMediaSource(contentMediaItem), + new DataSpec(Uri.EMPTY), + "adsId", + defaultMediaSourceFactory, + adsLoader, + mockAdViewProvider); + // The content timeline with empty ad playback state. + contentWindowDefinition = + new FakeTimeline.TimelineWindowDefinition( + /* periodCount= */ 1, + "windowId", + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 90_000_000L, + /* defaultPositionUs= */ 0L, + /* windowOffsetInFirstPeriodUs= */ 0L, + ImmutableList.of(AdPlaybackState.NONE), + contentMediaItem); + // The ads timeline with a minimal ad playback state with the ads ID. + adsMediaSourceWindowDefinition = + new FakeTimeline.TimelineWindowDefinition( + /* periodCount= */ 1, + "windowId", + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 90_000_000L, + /* defaultPositionUs= */ 0L, + /* windowOffsetInFirstPeriodUs= */ 0L, + ImmutableList.of(new AdPlaybackState("adsId")), + contentMediaItem); + } + + @Test + public void setSupportedContentTypes_hlsNotSupported_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> adsLoader.setSupportedContentTypes(C.CONTENT_TYPE_DASH)); + } + + @Test + public void start_playerNotSet_throwIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> + adsLoader.start( + adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener)); + } + + @Test + public void start_nonHlsMediaItem_emptyAdPlaybackState() { + MediaItem mp4MediaItem = + new MediaItem.Builder() + .setUri("http:///example.com/media.mp4") + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.EMPTY).setAdsId("adsId").build()) + .build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + defaultMediaSourceFactory.createMediaSource(mp4MediaItem), + new DataSpec(Uri.EMPTY), + "adsId", + defaultMediaSourceFactory, + adsLoader, + mockAdViewProvider); + when(mockPlayer.getCurrentTimeline()) + .thenReturn( + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition( + /* periodCount= */ 1, + "windowId", + /* isSeekable= */ true, + /* isDynamic= */ true, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ C.TIME_UNSET, + /* defaultPositionUs= */ 0L, + /* windowOffsetInFirstPeriodUs= */ 0L, + ImmutableList.of(AdPlaybackState.NONE), + mp4MediaItem))); + adsLoader.setPlayer(mockPlayer); + + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + + verify(mockEventListener).onAdPlaybackState(new AdPlaybackState("adsId")); + } + + @Test + public void start_liveWindow_emptyAdPlaybackState() throws IOException { + when(mockPlayer.getCurrentTimeline()) + .thenReturn( + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition( + /* periodCount= */ 1, + "windowId", + /* isSeekable= */ true, + /* isDynamic= */ true, + /* isLive= */ true, + /* isPlaceholder= */ false, + /* durationUs= */ C.TIME_UNSET, + /* defaultPositionUs= */ 0L, + /* windowOffsetInFirstPeriodUs= */ 0L, + ImmutableList.of(AdPlaybackState.NONE), + contentMediaItem))); + 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)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + verify(mockAdsLoaderListener) + .onStart(contentMediaItem, adsMediaSource.getAdsId(), mockAdViewProvider); + + assertThrows( + IllegalStateException.class, + () -> + adsLoader.start( + adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener)); + + verifyNoMoreInteractions(mockAdsLoaderListener); + } + + @Test + public void start_twiceWithIdenticalUnsupportedAdsId_throwIllegalStateException() { + MediaItem mp4MediaItem = + new MediaItem.Builder() + .setUri("http:///example.com/media.mp4") + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.EMPTY).setAdsId("adsId").build()) + .build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + defaultMediaSourceFactory.createMediaSource(mp4MediaItem), + new DataSpec(Uri.EMPTY), + "adsId", + defaultMediaSourceFactory, + adsLoader, + mockAdViewProvider); + when(mockPlayer.getCurrentTimeline()) + .thenReturn( + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition( + /* periodCount= */ 1, + "windowId", + /* isSeekable= */ true, + /* isDynamic= */ true, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ C.TIME_UNSET, + /* defaultPositionUs= */ 0L, + /* windowOffsetInFirstPeriodUs= */ 0L, + ImmutableList.of(AdPlaybackState.NONE), + mp4MediaItem))); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + + assertThrows( + IllegalStateException.class, + () -> + adsLoader.start( + adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener)); + + verifyNoMoreInteractions(mockAdsLoaderListener); + } + + @Test + public void handleContentTimelineChanged_preMidAndPostRolls_translatedToAdPlaybackState() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:55.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-1.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad2\"," + + "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"; + + 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"))); + } + + @Test + public void handleContentTimelineChanged_3preRolls_mergedIntoSinglePreRollAdGroup() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-0-2.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", 0L) + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, 3) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 2, + MediaItem.fromUri("http://example.com/media-0-2.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_3midRolls_mergedIntoSingleMidRollAdGroup() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-2.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 4_000_000L) + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, 3) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 2, + MediaItem.fromUri("http://example.com/media-0-2.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_3postRolls_mergedIntoSinglePostRollAdGroup() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:30.000Z\"," + + "END-DATE=\"2020-01-02T21:55:31.000Z\"," + + "CUE=\"POST\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "CUE=\"POST\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "DURATION=1.1," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:51.000Z\"," + + "CUE=\"POST\"," + + "PLANNED-DURATION=1.2," + + "X-ASSET-URI=\"http://example.com/media-0-2.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, 1_100_000L, 1_200_000L) + .withAdCount(/* adGroupIndex= */ 0, 3) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_300_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 2, + MediaItem.fromUri("http://example.com/media-0-2.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_midRollAndPostRollNotInOrder_insertedCorrectly() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-2\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.500Z\"," + + "CUE=\"POST\"," + + "DURATION=3," + + "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:42.000Z\"," + + "DURATION=2.0," + + "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.000Z\"," + + "DURATION=1," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState( + "adsId", /* adGroupTimesUs...= */ 1_000_000L, 2_000_000L, C.TIME_END_OF_SOURCE) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L) + .withAdDurationsUs(/* adGroupIndex= */ 1, 2_000_000L) + .withAdDurationsUs(/* adGroupIndex= */ 2, 3_000_000L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 2, /* adCount= */ 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 1, 2_000_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 2, 3_000_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-1-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 2, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-2-0.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_resumeOffsetSetToZero_contentResumeOffsetUsIsZero() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "DURATION=1.0," + + "CUE=\"PRE\"," + + "X-RESUME-OFFSET=0.0," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "DURATION=1.0," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, 1_000_000L) + .withAdCount(/* adGroupIndex= */ 0, 2) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/media-0-1.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_unknownDuration_handledAsZeroForContentResumeOffsetUs() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "DURATION=1.0," + + "CUE=\"PRE\"," + + "X-RESUME-OFFSET=0.0," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, 2) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/media-0-1.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_playoutLimitSet_durationSetCorrectly() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "END-DATE=\"2020-01-02T21:55:42.123Z\"," + + "DURATION=2.0," + + "PLANNED-DURATION=3.0," + + "X-PLAYOUT-LIMIT=4.0," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 1_123_000L) + .withAdDurationsUs(/* adGroupIndex= */ 0, 4_000_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 4_000_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_withDurationSet_durationSetCorrectly() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "END-DATE=\"2020-01-02T21:55:42.246Z\"," + + "PLANNED-DURATION=2.000," + + "DURATION=3.456," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 1_123_000L) + .withAdDurationsUs(/* adGroupIndex= */ 0, 3_456_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_456_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_endDateSet_durationSetCorrectly() throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "END-DATE=\"2020-01-02T21:55:42.246Z\"," + + "PLANNED-DURATION=2.0," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 1_123_000L) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_123_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_123_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_withPlannedDurationSet_durationSetCorrectly() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "PLANNED-DURATION=2.234," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 1_123_000L) + .withAdDurationsUs(/* adGroupIndex= */ 0, 2_234_000L) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 2_234_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + } + + @Test + public void handleContentTimelineChanged_noDurationSet_durationTimeUnset() throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + + assertThat(callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader)) + .isEqualTo( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 1_123_000L) + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8"))); + } + + @Test + public void onPositionDiscontinuity_marksAdAsPlayed() throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n"; + callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + 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= */ 0, + /* adIndexInAdGroup= */ 1), + DISCONTINUITY_REASON_AUTO_TRANSITION); + listener + .getValue() + .onPositionDiscontinuity( + new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 0, + contentMediaItem, + periodUid, + /* periodIndex= */ 0, + /* positionMs= */ 10_000L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1), + new Player.PositionInfo( + windowUid, + /* mediaItemIndex= */ 0, + contentMediaItem, + periodUid, + /* periodIndex= */ 0, + /* positionMs= */ 0L, + /* contentPositionMs= */ 0L, + /* adGroupIndex= */ -1, + /* adIndexInAdGroup= */ -1), + DISCONTINUITY_REASON_AUTO_TRANSITION); + + verify(mockAdsLoaderListener) + .onAdCompleted( + contentMediaItem, + adsMediaSource.getAdsId(), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0); + verify(mockAdsLoaderListener) + .onAdCompleted( + contentMediaItem, + adsMediaSource.getAdsId(), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1); + verify(mockEventListener) + .onAdPlaybackState( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L) + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, /* contentResumeOffsetUs= */ 0) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/media-0-1.m3u8")) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)); + } + + @Test + public void onPlaybackStateChanged_stateEndedWhenPlayingAd_marksAdAsPlayed() throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "CUE=\"POST\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + when(mockPlayer.getCurrentTimeline()) + .thenReturn(new FakeTimeline(adsMediaSourceWindowDefinition)); + when(mockPlayer.isPlayingAd()).thenReturn(true); + when(mockPlayer.getCurrentPeriodIndex()).thenReturn(0); + when(mockPlayer.getCurrentMediaItem()).thenReturn(contentMediaItem); + when(mockPlayer.getCurrentAdGroupIndex()).thenReturn(0); + when(mockPlayer.getCurrentAdIndexInAdGroup()).thenReturn(0); + verify(mockPlayer).addListener(listener.capture()); + + listener.getValue().onPlaybackStateChanged(Player.STATE_ENDED); + + verify(mockAdsLoaderListener) + .onAdCompleted( + contentMediaItem, + adsMediaSource.getAdsId(), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0); + verify(mockEventListener) + .onAdPlaybackState( + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + .withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, /* contentResumeOffsetUs= */ 0) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/media-0-0.m3u8")) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void handlePrepareError_adPlaybackStateUpdatedAccordingly() throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n"; + callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + + adsLoader.handlePrepareError(adsMediaSource, 0, 1, new IOException()); + adsLoader.handlePrepareError(adsMediaSource, 0, 0, new IOException()); + + ArgumentCaptor adPlaybackState = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener, times(3)).onAdPlaybackState(adPlaybackState.capture()); + assertThat(adPlaybackState.getAllValues().get(0).getAdGroup(/* adGroupIndex= */ 0).states) + .isEqualTo(new int[] {1, 1}); + assertThat(adPlaybackState.getAllValues().get(1).getAdGroup(/* adGroupIndex= */ 0).states) + .isEqualTo(new int[] {1, 4}); + assertThat(adPlaybackState.getAllValues().get(2).getAdGroup(/* adGroupIndex= */ 0).states) + .isEqualTo(new int[] {4, 4}); + } + + @Test + public void onMetadata_listenerCallbackCalled() { + Metadata metadata = new Metadata(/* presentationTimeUs= */ 0L); + when(mockPlayer.getCurrentMediaItem()).thenReturn(contentMediaItem); + when(mockPlayer.isPlayingAd()).thenReturn(true); + when(mockPlayer.getCurrentAdGroupIndex()).thenReturn(1); + when(mockPlayer.getCurrentAdIndexInAdGroup()).thenReturn(2); + when(mockPlayer.getCurrentTimeline()) + .thenReturn(new FakeTimeline(adsMediaSourceWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + verify(mockPlayer).addListener(listener.capture()); + + listener.getValue().onMetadata(metadata); + + InOrder inOrder = inOrder(mockAdsLoaderListener); + inOrder + .verify(mockAdsLoaderListener) + .onStart(contentMediaItem, adsMediaSource.getAdsId(), mockAdViewProvider); + inOrder + .verify(mockAdsLoaderListener) + .onMetadata( + contentMediaItem, + adsMediaSource.getAdsId(), + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 2, + metadata); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void onMetadata_differentMediaItem_listenerCallbackNotCalled() { + Metadata metadata = new Metadata(/* presentationTimeUs= */ 0L); + when(mockPlayer.getCurrentMediaItem()).thenReturn(MediaItem.fromUri(Uri.EMPTY)); + when(mockPlayer.isPlayingAd()).thenReturn(true); + when(mockPlayer.getCurrentAdGroupIndex()).thenReturn(1); + when(mockPlayer.getCurrentAdIndexInAdGroup()).thenReturn(2); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + verify(mockPlayer).addListener(listener.capture()); + + listener.getValue().onMetadata(metadata); + + verify(mockAdsLoaderListener) + .onStart(contentMediaItem, adsMediaSource.getAdsId(), mockAdViewProvider); + verifyNoMoreInteractions(mockAdsLoaderListener); + } + + @Test + public void onMetadata_noAdIsPlaying_listenerCallbackNotCalled() { + Metadata metadata = new Metadata(/* presentationTimeUs= */ 0L); + when(mockPlayer.getCurrentMediaItem()).thenReturn(contentMediaItem); + when(mockPlayer.isPlayingAd()).thenReturn(false); + when(mockPlayer.getCurrentAdGroupIndex()).thenReturn(-1); + when(mockPlayer.getCurrentAdIndexInAdGroup()).thenReturn(-1); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + verify(mockPlayer).addListener(listener.capture()); + + listener.getValue().onMetadata(metadata); + + verify(mockAdsLoaderListener) + .onStart(contentMediaItem, adsMediaSource.getAdsId(), mockAdViewProvider); + verifyNoMoreInteractions(mockAdsLoaderListener); + } + + @Test + public void stop_playerListenerRemoved() { + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + 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); + + adsLoader.stop(adsMediaSource, mockEventListener); + + verify(mockPlayer).removeListener(listener.getValue()); + verifyNoMoreInteractions(mockPlayer); + } + + @Test + public void release_neverStarted_playerListenerNotAddedNorRemoved() { + adsLoader.setPlayer(mockPlayer); + + adsLoader.release(); + + verifyNoMoreInteractions(mockPlayer); + } + + @Test + public void release_afterStartButBeforeStopped_playerListenerRemovedAfterAllSourcesStopped() { + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + 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); + + adsLoader.release(); + adsLoader.handleContentTimelineChanged( + adsMediaSource, new FakeTimeline(contentWindowDefinition)); + adsLoader.stop(adsMediaSource, mockEventListener); + + verify(mockPlayer).removeListener(listener.capture()); + verifyNoMoreInteractions(mockPlayer); + } + + @Test + public void start_whenReleased_keepsPlaybackOnGoingAndNoListenerCalled() { + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.release(); + + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + adsLoader.handleContentTimelineChanged( + adsMediaSource, new FakeTimeline(contentWindowDefinition)); + adsLoader.stop(adsMediaSource, mockEventListener); + + verifyNoMoreInteractions(mockAdsLoaderListener); + verify(mockEventListener).onAdPlaybackState(new AdPlaybackState("adsId")); + verifyNoMoreInteractions(mockEventListener); + } + + @Test + public void + handleContentTimelineChanged_whenReleasedWithStartedSource_keepsPlaybackOnGoingAndNoListenerCalled() + throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "DURATION=15," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + HlsMediaPlaylist contentMediaPlaylist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(Uri.EMPTY, inputStream); + HlsManifest hlsManifest = + new HlsManifest(/* multivariantPlaylist= */ null, contentMediaPlaylist); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + // Set the player. + adsLoader.setPlayer(mockPlayer); + // Start the ad. + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + reset(mockEventListener); + reset(mockAdsLoaderListener); + + adsLoader.release(); + adsLoader.handleContentTimelineChanged( + adsMediaSource, new FakeTimeline(new Object[] {hlsManifest}, contentWindowDefinition)); + adsLoader.stop(adsMediaSource, mockEventListener); + + verifyNoMoreInteractions(mockAdsLoaderListener); + verify(mockEventListener).onAdPlaybackState(new AdPlaybackState("adsId")); + verifyNoMoreInteractions(mockEventListener); + } + + @Test + public void setPlayer_nulledWithStartedSource_doesNotCrashAndListenerCalled() throws IOException { + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:40.000Z\"," + + "DURATION=15," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n"; + callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + reset(mockPlayer); + reset(mockEventListener); + reset(mockAdsLoaderListener); + + adsLoader.setPlayer(null); + adsLoader.handleContentTimelineChanged( + adsMediaSource, new FakeTimeline(contentWindowDefinition)); + adsLoader.handlePrepareError( + adsMediaSource, /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); + adsLoader.handlePrepareComplete( + adsMediaSource, /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + adsLoader.stop(adsMediaSource, mockEventListener); + + verify(mockPlayer).removeListener(any()); + verifyNoMoreInteractions(mockPlayer); + verify(mockEventListener).onAdPlaybackState(any()); + verifyNoMoreInteractions(mockEventListener); + InOrder inOrder = inOrder(mockAdsLoaderListener); + inOrder.verify(mockAdsLoaderListener).onPrepareError(any(), any(), anyInt(), anyInt(), any()); + inOrder.verify(mockAdsLoaderListener).onPrepareCompleted(any(), any(), anyInt(), anyInt()); + inOrder.verify(mockAdsLoaderListener).onStop(any(), any(), any()); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void setPlayer_playerAlreadySetWithActiveListeners_throwIllegalArgumentException() { + Player secondMockPlayer = mock(Player.class); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + + assertThrows(IllegalStateException.class, () -> adsLoader.setPlayer(secondMockPlayer)); + } + + @Test + public void setPlayer_playerAlreadySetWithoutActiveListeners_playerSet() { + Player secondMockPlayer = mock(Player.class); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + + adsLoader.setPlayer(secondMockPlayer); + + verifyNoMoreInteractions(mockPlayer); + verifyNoMoreInteractions(secondMockPlayer); + } + + @Test + public void setPlayer_setToNullWithActiveListeners_playerListenerRemoved() { + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + reset(mockPlayer); + + adsLoader.setPlayer(null); + + verify(mockPlayer).removeListener(any()); + verifyNoMoreInteractions(mockPlayer); + } + + @Test + public void setPlayer_setToNullWithoutActiveListeners_playerSet() { + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + + adsLoader.setPlayer(/* player= */ null); + + verifyNoMoreInteractions(mockPlayer); + } + + @Test + public void addRemoveListener_listenerNotifiedWhenAdded() { + adsLoader.setPlayer(mockPlayer); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + HlsInterstitialsAdsLoader.Listener mockAdsLoaderListener2 = + mock(HlsInterstitialsAdsLoader.Listener.class); + + // add a second listener and trigger onStart callback + adsLoader.addListener(mockAdsLoaderListener2); + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + + verify(mockAdsLoaderListener2).onStart(any(), any(), any()); + verify(mockAdsLoaderListener).onStart(any(), any(), any()); + + // remove the second listener and trigger onStop callback + adsLoader.removeListener(mockAdsLoaderListener2); + adsLoader.stop(adsMediaSource, mockEventListener); + + verifyNoMoreInteractions(mockAdsLoaderListener2); + verify(mockAdsLoaderListener).onStop(any(), any(), any()); + verifyNoMoreInteractions(mockAdsLoaderListener); + } + + @Test + public void listener_wholeLifecycle_adsLoaderListenerCallbacksCorrectlyCalled() + throws IOException { + Metadata metadata = new Metadata(/* presentationTimeUs= */ 0L); + when(mockPlayer.isPlayingAd()).thenReturn(true); + when(mockPlayer.getCurrentMediaItem()).thenReturn(contentMediaItem); + when(mockPlayer.getCurrentAdGroupIndex()).thenReturn(0); + when(mockPlayer.getCurrentAdIndexInAdGroup()).thenReturn(1); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" + + "#EXTINF:6,\n" + + "main1.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-0\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:41.123Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad0-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:43.123Z\"," + + "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\"" + + "\n"; + ArgumentCaptor adPlaybackState = + ArgumentCaptor.forClass(AdPlaybackState.class); + IOException exception = new IOException(); + ArgumentCaptor listener = ArgumentCaptor.forClass(Player.Listener.class); + + callHandleContentTimelineChangedAndCaptureAdPlaybackState(playlistString, adsLoader); + adsLoader.handlePrepareError( + adsMediaSource, /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, exception); + adsLoader.handlePrepareComplete( + adsMediaSource, /* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0); + verify(mockPlayer).addListener(listener.capture()); + listener.getValue().onMetadata(metadata); + adsLoader.stop(adsMediaSource, mockEventListener); + + InOrder inOrder = inOrder(mockAdsLoaderListener); + inOrder + .verify(mockAdsLoaderListener) + .onStart(contentMediaItem, adsMediaSource.getAdsId(), mockAdViewProvider); + inOrder + .verify(mockAdsLoaderListener) + .onContentTimelineChanged(eq(contentMediaItem), eq(adsMediaSource.getAdsId()), any()); + inOrder + .verify(mockAdsLoaderListener) + .onPrepareError(contentMediaItem, adsMediaSource.getAdsId(), 0, 0, exception); + inOrder + .verify(mockAdsLoaderListener) + .onPrepareCompleted(contentMediaItem, adsMediaSource.getAdsId(), 1, 0); + inOrder + .verify(mockAdsLoaderListener) + .onMetadata(contentMediaItem, adsMediaSource.getAdsId(), 0, 1, metadata); + inOrder + .verify(mockAdsLoaderListener) + .onStop(eq(contentMediaItem), eq(adsMediaSource.getAdsId()), adPlaybackState.capture()); + inOrder.verifyNoMoreInteractions(); + assertThat(adPlaybackState.getValue().getAdGroup(/* adGroupIndex= */ 0).states) + .isEqualTo(new int[] {4}); + assertThat(adPlaybackState.getValue().getAdGroup(/* adGroupIndex= */ 1).states) + .isEqualTo(new int[] {1}); + } + + @Test + public void listener_unsupportedMediaItem_adsLoaderListenerNotCalled() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + defaultMediaSourceFactory.createMediaSource( + MediaItem.fromUri("https://example.com/media.mp4")), + new DataSpec(Uri.EMPTY), + "adsId", + defaultMediaSourceFactory, + adsLoader, + mockAdViewProvider); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + adsLoader.setPlayer(mockPlayer); + + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + adsLoader.handleContentTimelineChanged( + adsMediaSource, new FakeTimeline(contentWindowDefinition)); + adsLoader.stop(adsMediaSource, mockEventListener); + + verifyNoMoreInteractions(mockAdsLoaderListener); + verify(mockEventListener).onAdPlaybackState(new AdPlaybackState("adsId")); + verifyNoMoreInteractions(mockEventListener); + } + + private AdPlaybackState callHandleContentTimelineChangedAndCaptureAdPlaybackState( + String playlistString, HlsInterstitialsAdsLoader adsLoader) throws IOException { + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + HlsMediaPlaylist contentMediaPlaylist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(Uri.EMPTY, inputStream); + when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); + // Set the player. + adsLoader.setPlayer(mockPlayer); + // Start the ad. + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + + // Notify ads loader about the media playlist. + HlsManifest hlsManifest = + new HlsManifest(/* multivariantPlaylist= */ null, contentMediaPlaylist); + adsLoader.handleContentTimelineChanged( + adsMediaSource, new FakeTimeline(new Object[] {hlsManifest}, contentWindowDefinition)); + + ArgumentCaptor adPlaybackState = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener).onAdPlaybackState(adPlaybackState.capture()); + when(mockPlayer.getCurrentTimeline()) + .thenReturn(new FakeTimeline(adsMediaSourceWindowDefinition)); + return adPlaybackState.getValue(); + } +}