diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d3b8a1eab4..5da5498ec1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -69,6 +69,7 @@ * Cronet extension: * RTMP extension: * HLS extension: + * Support X-ASSET-LIST and live streams with `HlsInterstitialsAdsLoader`. * DASH extension: * Smooth Streaming extension: * RTSP extension: 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 53a5912830..4f439ead8e 100644 --- a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java +++ b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java @@ -596,6 +596,21 @@ public final class AdPlaybackState { return C.INDEX_UNSET; } + /** Returns a safe copy with all array fields copied into the new instance as new arrays. */ + public AdGroup copy() { + return new AdGroup( + timeUs, + count, + originalCount, + Arrays.copyOf(states, states.length), + Arrays.copyOf(mediaItems, mediaItems.length), + Arrays.copyOf(durationsUs, durationsUs.length), + contentResumeOffsetUs, + isServerSideInserted, + Arrays.copyOf(ids, ids.length), + isPlaceholder); + } + @CheckResult private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) { int oldStateCount = states.length; @@ -944,6 +959,20 @@ public final class AdPlaybackState { adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount); } + /** + * Returns an new instance that is a safe deep copy of this instance in case an immutable object + * is used for {@link #adsId}. + */ + @CheckResult + public AdPlaybackState copy() { + AdGroup[] adGroups = new AdGroup[this.adGroups.length]; + for (int i = 0; i < adGroups.length; i++) { + adGroups[i] = this.adGroups[i].copy(); + } + return new AdPlaybackState( + adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount); + } + /** * @deprecated Use {@link #withAvailableAdMediaItem} instead. */ diff --git a/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java b/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java index 1234b98a5d..5fe7c4b3fa 100644 --- a/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java @@ -26,6 +26,7 @@ import static org.junit.Assert.fail; import android.net.Uri; import android.os.Bundle; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.lang.reflect.Field; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -1201,4 +1202,65 @@ public class AdPlaybackStateTest { assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 3).durationsUs).hasLength(0); } + + @SuppressWarnings("deprecation") // testing deprecated field `uris` + @Test + public void copy() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId", 10_000L) + .withLivePostrollPlaceholderAppended(false) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/0-0")) + .withNewAdGroup(/* adGroupIndex= */ 1, 11_000) + .withAdCount(/* adGroupIndex= */ 1, 2) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/1-0")) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("http://example.com/1-1")) + .withNewAdGroup(/* adGroupIndex= */ 2, 12_000); + + AdPlaybackState copy = adPlaybackState.copy(); + + assertThat(copy).isEqualTo(adPlaybackState); + assertThat(copy).isNotSameInstanceAs(adPlaybackState); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + AdPlaybackState.AdGroup adGroupCopy = copy.getAdGroup(adGroupIndex); + AdPlaybackState.AdGroup originalAdGroup = adPlaybackState.getAdGroup(adGroupIndex); + assertThat(adGroupCopy).isNotSameInstanceAs(originalAdGroup); + assertThat(adGroupCopy.durationsUs).isNotSameInstanceAs(originalAdGroup.durationsUs); + assertThat(adGroupCopy.ids).isNotSameInstanceAs(originalAdGroup.ids); + assertThat(adGroupCopy.mediaItems).isNotSameInstanceAs(originalAdGroup.mediaItems); + assertThat(adGroupCopy.states).isNotSameInstanceAs(originalAdGroup.states); + assertThat(adGroupCopy.uris).isNotSameInstanceAs(originalAdGroup.uris); + } + } + + /** + * If this test fails a new field of type array has been added to {@link AdPlaybackState.AdGroup}. + * Make sure to update {@link AdPlaybackState.AdGroup#copy} and add a line in the test {@link + * #copy()} to verify that the new array field has been copied as a new array instance. Then + * increment the expected count in this test case. + */ + @Test + public void adGroup_numberOfFieldsOfTypeArray_hasNotChanged() { + // 5 fields of type array durationsUs, ids, mediaItems, states, uris. + int expectedNumberOfFieldsOfTypeArray = 5; + Class clazz = AdPlaybackState.AdGroup.class; + Field[] fields = clazz.getFields(); + int arrayFieldCount = 0; + for (Field field : fields) { + if (field.getType().isArray()) { + arrayFieldCount++; + } + } + + assertThat(arrayFieldCount).isEqualTo(expectedNumberOfFieldsOfTypeArray); + } } 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 9885141c48..cdac82fa23 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 @@ -40,6 +40,7 @@ import static java.lang.Math.min; import android.content.Context; import android.net.Uri; +import android.os.Bundle; import android.os.Looper; import androidx.annotation.Nullable; import androidx.media3.common.AdPlaybackState; @@ -94,6 +95,7 @@ import java.util.TreeMap; * 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. */ +@SuppressWarnings("PatternMatchingInstanceof") @UnstableApi public final class HlsInterstitialsAdsLoader implements AdsLoader { @@ -205,6 +207,67 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } } + /** + * The state of the given ads ID to resume playback at the given {@link AdPlaybackState}. + * + *

This state object can be bundled and unbundled while preserving an {@link + * AdPlaybackState#adsId ads ID} of type {@link String}. + */ + public static class AdsResumptionState { + + private final AdPlaybackState adPlaybackState; + + /** The ads ID */ + public final String adsId; + + /** + * Creates a new instance. + * + * @param adsId The ads ID of the playback state. + * @param adPlaybackState The {@link AdPlaybackState} with the given {@code adsId}. + * @throws IllegalArgumentException Thrown if the passed in adsId is not equal to {@link + * AdPlaybackState#adsId}. + */ + public AdsResumptionState(String adsId, AdPlaybackState adPlaybackState) { + checkArgument(adsId.equals(adPlaybackState.adsId)); + this.adsId = adsId; + this.adPlaybackState = adPlaybackState; + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof AdsResumptionState)) { + return false; + } + AdsResumptionState adsResumptionState = (AdsResumptionState) o; + return Objects.equals(adsId, adsResumptionState.adsId) + && Objects.equals(adPlaybackState, adsResumptionState.adPlaybackState); + } + + @Override + public int hashCode() { + return Objects.hash(adsId, adPlaybackState); + } + + private static final String FIELD_ADS_ID = Util.intToStringMaxRadix(0); + private static final String FIELD_AD_PLAYBACK_STATE = Util.intToStringMaxRadix(1); + + public Bundle toBundle() { + Bundle bundle = new Bundle(); + bundle.putString(FIELD_ADS_ID, adsId); + bundle.putBundle(FIELD_AD_PLAYBACK_STATE, adPlaybackState.toBundle()); + return bundle; + } + + public static AdsResumptionState fromBundle(Bundle bundle) { + String adsId = checkNotNull(bundle.getString(FIELD_ADS_ID)); + AdPlaybackState adPlaybackState = + AdPlaybackState.fromBundle(checkNotNull(bundle.getBundle(FIELD_AD_PLAYBACK_STATE))) + .withAdsId(adsId); + return new AdsResumptionState(adsId, adPlaybackState); + } + } + /** * A {@link MediaSource.Factory} to create a media source to play HLS streams with interstitials. */ @@ -474,6 +537,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { private final Map activeAdPlaybackStates; private final Map> insertedInterstitialIds; private final Map> unresolvedAssetLists; + private final Map resumptionStates; private final List listeners; private final Set unsupportedAdsIds; @@ -504,6 +568,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { activeAdPlaybackStates = new HashMap<>(); insertedInterstitialIds = new HashMap<>(); unresolvedAssetLists = new HashMap<>(); + resumptionStates = new HashMap<>(); listeners = new ArrayList<>(); unsupportedAdsIds = new HashSet<>(); } @@ -553,6 +618,99 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { throw new IllegalArgumentException(); } + /** + * Returns the resumption states of the currently active {@link AdsMediaSource ads media sources}. + * + *

Call this method to get the resumption states before releasing the player and {@linkplain + * #addAdResumptionState(AdsResumptionState) resume at the same state later}. + * + *

Live streams and streams with an {@linkplain AdsMediaSource#getAdsId() ads ID} that are not + * of type string are ignored and are not included in the returned list of ad resumption state. + * + *

See {@link HlsInterstitialsAdsLoader.Listener#onStop(MediaItem, Object, AdPlaybackState)} + * and {@link #addAdResumptionState(Object, AdPlaybackState)} also. + */ + public ImmutableList getAdsResumptionStates() { + ImmutableList.Builder resumptionStates = new ImmutableList.Builder<>(); + for (AdPlaybackState adPlaybackState : activeAdPlaybackStates.values()) { + boolean isLiveStream = adPlaybackState.endsWithLivePostrollPlaceHolder(); + if (!isLiveStream && adPlaybackState.adsId instanceof String) { + resumptionStates.add( + new AdsResumptionState((String) adPlaybackState.adsId, adPlaybackState.copy())); + } else { + Log.i( + TAG, + isLiveStream + ? "getAdsResumptionStates(): ignoring active ad playback state of live stream." + + " adsId=" + + adPlaybackState.adsId + : "getAdsResumptionStates(): ignoring active ad playback state when creating" + + " resumption states. `adsId` is not of type String: " + + castNonNull(adPlaybackState.adsId).getClass()); + } + } + return resumptionStates.build(); + } + + /** + * Adds the given {@link AdsResumptionState} to resume playback of the {@link AdsMediaSource} with + * {@linkplain AdsMediaSource#getAdsId() ads ID} at the provided ad playback state. + * + *

If added while the given ads ID is active, the resumption state is ignored. The resumption + * state for a given ads ID must be added before {@link #start(AdsMediaSource, DataSpec, Object, + * AdViewProvider, EventListener)} or after {@link #stop(AdsMediaSource, EventListener)} is called + * for that ads ID. + * + * @param adsResumptionState The state to resume with. + * @throws IllegalArgumentException Thrown if the ad playback state {@linkplain + * AdPlaybackState#endsWithLivePostrollPlaceHolder() ends with a live placeholder}. + */ + public void addAdResumptionState(AdsResumptionState adsResumptionState) { + addAdResumptionState(adsResumptionState.adsId, adsResumptionState.adPlaybackState); + } + + /** + * Adds the given {@link AdPlaybackState} to resume playback of the {@link AdsMediaSource} with + * {@linkplain AdsMediaSource#getAdsId() ads ID} at the provided ad playback state. + * + *

If added while the given ads ID is active, the resumption state is ignored. The resumption + * state for a given ads ID must be added before {@link #start(AdsMediaSource, DataSpec, Object, + * AdViewProvider, EventListener)} or after {@link #stop(AdsMediaSource, EventListener)} is called + * for that ads ID. + * + * @param adsId The ads ID identifying the {@link AdsMediaSource} to resume with the given state. + * @param adPlaybackState The state to resume with. + * @throws IllegalArgumentException Thrown if the ad playback state {@linkplain + * AdPlaybackState#endsWithLivePostrollPlaceHolder() ends with a live placeholder}. + */ + public void addAdResumptionState(Object adsId, AdPlaybackState adPlaybackState) { + checkArgument(!adPlaybackState.endsWithLivePostrollPlaceHolder()); + if (!activeAdPlaybackStates.containsKey(adsId)) { + resumptionStates.put(adsId, adPlaybackState.copy().withAdsId(adsId)); + } else { + Log.w( + TAG, + "Attempting to add an ad resumption state for an adsId that is currently active. adsId=" + + adsId); + } + } + + /** + * Removes the {@link AdsResumptionState} for the given ads ID, or null if there is no active ad + * playback state for the given ads ID. + * + * @param adsId The ads ID for which to remove the resumption state. + * @return The removed resumption state or null. + */ + public boolean removeAdResumptionState(Object adsId) { + return resumptionStates.remove(adsId) != null; + } + + /** Clears all ad resumptions states. */ + public void clearAllAdResumptionStates() { + resumptionStates.clear(); + } + @Override public void start( AdsMediaSource adsMediaSource, @@ -578,14 +736,19 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { activeEventListeners.put(adsId, eventListener); MediaItem mediaItem = adsMediaSource.getMediaItem(); if (isHlsMediaItem(mediaItem)) { - // Mark with NONE. Update and notify later when timeline with interstitials arrives. - activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE); insertedInterstitialIds.put(adsId, new HashSet<>()); unresolvedAssetLists.put(adsId, new TreeMap<>()); + if (adsId instanceof String && resumptionStates.containsKey(adsId)) { + // Use resumption playback state. Interstitials arriving with the timeline are ignored. + putAndNotifyAdPlaybackStateUpdate(adsId, checkNotNull(resumptionStates.remove(adsId))); + } else { + // Mark with NONE and wait for the timeline to get interstitials from the HLS playlist. + activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE); + } notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider)); } else { - putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId)); Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId); + putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId)); unsupportedAdsIds.add(adsId); } } @@ -714,6 +877,12 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { } } if (!isReleased && !unsupportedAdsIds.contains(adsId)) { + if (adPlaybackState != null + && adsId instanceof String + && resumptionStates.containsKey(adsId)) { + // Update the resumption state in case the user has added one. + resumptionStates.put(adsId, adPlaybackState); + } notifyListeners( listener -> listener.onStop( @@ -740,6 +909,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader { if (activeEventListeners.isEmpty()) { player = null; } + clearAllAdResumptionStates(); cancelPendingAssetListResolutionMessage(); if (loader != null) { loader.release(); 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 30b03a3a73..f5119359c5 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 @@ -53,6 +53,7 @@ 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.AdsResumptionState; import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.Asset; import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; @@ -60,6 +61,7 @@ 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.FakeMediaSource; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.test.core.app.ApplicationProvider; @@ -256,6 +258,270 @@ public class HlsInterstitialsAdsLoaderTest { verifyNoMoreInteractions(mockAdsLoaderListener); } + @Test + public void start_resumptionStateAvailable_resumptionStateUsedAndEventListenerCalled() { + AdPlaybackState adPlaybackState = + new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0, 10L, C.TIME_END_OF_SOURCE) + .withAdCount(/* adGroupIndex= */ 0, 1) + .withAdCount(/* adGroupIndex= */ 1, 2) + .withAdCount(/* adGroupIndex= */ 2, 3) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com")); + adsLoader.addAdResumptionState(new AdsResumptionState("adsId", adPlaybackState)); + adsLoader.setPlayer(mockPlayer); + + adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener); + adsLoader.stop(adsMediaSource, mockEventListener); + + ArgumentCaptor adPlaybackStateArgumentCaptor = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockEventListener).onAdPlaybackState(adPlaybackStateArgumentCaptor.capture()); + verify(mockAdsLoaderListener) + .onStop(any(), eq("adsId"), adPlaybackStateArgumentCaptor.capture()); + assertThat(adPlaybackStateArgumentCaptor.getAllValues()) + .containsExactly(adPlaybackState, adPlaybackState); + verify(mockAdsLoaderListener).onStart(eq(contentMediaItem), eq("adsId"), isNotNull()); + assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse(); + } + + @Test + public void addAdResumptionState_whileAdsIdIsActive_ignored() throws IOException { + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + "#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" + + "#EXTINF:6,\n" + + "main2.0.ts\n" + + "#EXTINF:6,\n" + + "main3.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\"," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n", + adsLoader, + /* windowIndex= */ 0, + /* windowPositionInPeriodUs= */ 0, + /* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE); + + adsLoader.addAdResumptionState(new AdsResumptionState("adsId", new AdPlaybackState("adsId"))); + + assertThat(adsLoader.getAdsResumptionStates()) + .containsExactly( + new AdsResumptionState( + "adsId", + new AdPlaybackState("adsId", 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType("application/x-mpegURL") + .build()))); + assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse(); + } + + @Test + public void addAdResumptionState_withLivePostRollHolder_throwsIllegalArgumentException() { + AdsResumptionState adsResumptionState = + new AdsResumptionState( + "adsId", + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)); + + assertThrows( + IllegalArgumentException.class, () -> adsLoader.addAdResumptionState(adsResumptionState)); + } + + @Test + public void getAdsResumptionStates_withLivePostRollPlaceholder_ignored() throws IOException { + List adPlaybackStates = + callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( + adsLoader, + /* startAdsLoader= */ true, + /* windowOffsetInFirstPeriodUs= */ 0L, + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n" + + "#EXTINF:6,\nmain0.0.ts\n" + + "#EXTINF:6,\nmain1.0.ts\n" + + "#EXTINF:6,\nmain2.0.ts\n" + + "#EXTINF:6,\nmain3.0.ts\n" + + "#EXTINF:6,\nmain4.0.ts\n" + + "\n"); + + // active ad playback state with live post roll is ignored. + assertThat(adsLoader.getAdsResumptionStates()).isEmpty(); + + // Stop to verify that there was an active ad playback state when calling getAdResumptionStates. + adsLoader.stop(adsMediaSource, mockEventListener); + ArgumentCaptor adPlaybackState = + ArgumentCaptor.forClass(AdPlaybackState.class); + verify(mockAdsLoaderListener).onStop(any(), eq("adsId"), adPlaybackState.capture()); + assertThat(adPlaybackState.getAllValues()).isEqualTo(adPlaybackStates); + } + + @Test + public void getAdsResumptionStates_returnsResumptionStateOfActiveAdsIds() throws IOException { + String secondPlaylistString = + "#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" + + "#EXTINF:6,\n" + + "main2.0.ts\n" + + "#EXTINF:6,\n" + + "main3.0.ts\n" + + "#EXT-X-ENDLIST" + + "\n" + + "#EXT-X-DATERANGE:" + + "ID=\"ad1-1\"," + + "CLASS=\"com.apple.hls.interstitial\"," + + "START-DATE=\"2020-01-02T21:55:44.000Z\"," + + "CUE=\"POST\"," + + "X-ASSET-URI=\"http://example.com/media-1-1.m3u8\"" + + "\n"; + HlsMediaPlaylist secondMediaPlaylist = + (HlsMediaPlaylist) + new HlsPlaylistParser() + .parse( + Uri.EMPTY, new ByteArrayInputStream(Util.getUtf8Bytes(secondPlaylistString))); + HlsManifest secondHlsManifest = + new HlsManifest(/* multivariantPlaylist= */ null, secondMediaPlaylist); + TimelineWindowDefinition secondInitialTimelineWindowDefinition = + new TimelineWindowDefinition.Builder() + .setPlaceholder(true) + .setDynamic(true) + .setDurationUs(C.TIME_UNSET) + .setWindowPositionInFirstPeriodUs(0) + .setMediaItem(MediaItem.fromUri("http://example.com/2.m3u8")) + .build(); + AdsMediaSource secondAdsMediaSource = + new AdsMediaSource( + new FakeMediaSource(new FakeTimeline(secondInitialTimelineWindowDefinition)), + new DataSpec(secondInitialTimelineWindowDefinition.mediaItem.localConfiguration.uri), + "adsId2", + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()), + adsLoader, + mockAdViewProvider); + AdsResumptionState firstAdsResumptionState = + new AdsResumptionState( + "adsId", + new AdPlaybackState("adsId", 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-0-0.m3u8") + .setMimeType("application/x-mpegURL") + .build())); + AdsResumptionState secondAdsResumptionState = + new AdsResumptionState( + "adsId2", + new AdPlaybackState("adsId2", C.TIME_END_OF_SOURCE) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad1-1") + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + new MediaItem.Builder() + .setUri("http://example.com/media-1-1.m3u8") + .setMimeType("application/x-mpegURL") + .build())); + + // Start the first adsId with a pre roll. + callHandleContentTimelineChangedAndCaptureAdPlaybackState( + "#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" + + "#EXTINF:6,\n" + + "main2.0.ts\n" + + "#EXTINF:6,\n" + + "main3.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\"," + + "CUE=\"PRE\"," + + "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\"" + + "\n", + adsLoader, + /* windowIndex= */ 0, + /* windowPositionInPeriodUs= */ 0, + /* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE); + + assertThat(adsLoader.getAdsResumptionStates()).containsExactly(firstAdsResumptionState); + + // Start a second adsId with a post roll. + adsLoader.start( + secondAdsMediaSource, + new DataSpec(Uri.EMPTY), + "adsId2", + mockAdViewProvider, + mockEventListener); + adsLoader.handleContentTimelineChanged( + secondAdsMediaSource, + new FakeTimeline( + new Object[] {secondHlsManifest}, + secondInitialTimelineWindowDefinition + .buildUpon() + .setDurationUs(secondMediaPlaylist.durationUs) + .setDynamic(false) + .setPlaceholder(false) + .build())); + + assertThat(adsLoader.getAdsResumptionStates()) + .containsExactly(firstAdsResumptionState, secondAdsResumptionState); + + // Stop the first ads media source. + adsLoader.stop(adsMediaSource, mockEventListener); + + assertThat(adsLoader.getAdsResumptionStates()).containsExactly(secondAdsResumptionState); + + // Stop the second ads media source. + adsLoader.stop(secondAdsMediaSource, mockEventListener); + + assertThat(adsLoader.getAdsResumptionStates()).isEmpty(); + } + + @Test + public void removeAdResumptionState_removesAvailableResumptionState() { + AdsResumptionState adsResumptionState = + new AdsResumptionState("adsId", new AdPlaybackState("adsId")); + adsLoader.addAdResumptionState(adsResumptionState); + + assertThat(adsLoader.removeAdResumptionState("adsId")).isTrue(); + assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse(); + } + + @Test + public void clearAllAdResumptionStates_removesAvailableResumptionState() { + adsLoader.addAdResumptionState(new AdsResumptionState("adsId", new AdPlaybackState("adsId"))); + adsLoader.addAdResumptionState("adsId2", new AdPlaybackState("adsId2")); + + adsLoader.clearAllAdResumptionStates(); + + assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse(); + } + @Test public void handleContentTimelineChanged_preMidAndPostRolls_translatedToAdPlaybackState() throws IOException { @@ -3409,6 +3675,16 @@ public class HlsInterstitialsAdsLoaderTest { verifyNoMoreInteractions(mockPlayer); } + @Test + public void release_clearsResumptionStates() { + adsLoader.addAdResumptionState( + "adsId", new AdPlaybackState(/* adsId= */ "adsId", 0L, C.TIME_END_OF_SOURCE)); + + adsLoader.release(); + + assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse(); + } + @Test public void release_afterStartButBeforeStopped_playerListenerRemovedAfterAllSourcesStopped() { when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); @@ -3700,6 +3976,29 @@ public class HlsInterstitialsAdsLoaderTest { verifyNoMoreInteractions(mockEventListener); } + @Test + public void state_bundleUnbundleRoundTrip_createsEqualInstance() { + AdPlaybackState adPlaybackState = + new AdPlaybackState( + /* adsId= */ "1234", /* adGroupTimesUs...= */ 0L, 10L, C.TIME_END_OF_SOURCE) + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ true) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + AdsResumptionState adsResumptionState = new AdsResumptionState("1234", adPlaybackState); + + AdsResumptionState resultingAdsResumptionState = + AdsResumptionState.fromBundle(adsResumptionState.toBundle()); + + assertThat(resultingAdsResumptionState).isEqualTo(adsResumptionState); + } + + @Test + public void state_constructorWithAdsIdsThatDoNotMatch_throwsIllegalArgumentException() { + AdPlaybackState adPlaybackState = new AdPlaybackState("1234"); + + assertThrows( + IllegalArgumentException.class, () -> new AdsResumptionState("5678", adPlaybackState)); + } + private List callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( HlsInterstitialsAdsLoader adsLoader, boolean startAdsLoader,