mirror of
https://github.com/androidx/media.git
synced 2025-04-29 22:36:54 +08:00
Make HlsInterstitialsAdsLoader resumable
This allows the app to store the ad playback state and then when starting again, allow them to resume with the same ad playback state. This way, users don't have to watch the same ads twice if/when playback was interrupted. For instance, when the app was put into background and then is foregrounded again. PiperOrigin-RevId: 748737832
This commit is contained in:
parent
7f6ddef502
commit
d0833c4e7c
@ -69,6 +69,7 @@
|
|||||||
* Cronet extension:
|
* Cronet extension:
|
||||||
* RTMP extension:
|
* RTMP extension:
|
||||||
* HLS extension:
|
* HLS extension:
|
||||||
|
* Support X-ASSET-LIST and live streams with `HlsInterstitialsAdsLoader`.
|
||||||
* DASH extension:
|
* DASH extension:
|
||||||
* Smooth Streaming extension:
|
* Smooth Streaming extension:
|
||||||
* RTSP extension:
|
* RTSP extension:
|
||||||
|
@ -596,6 +596,21 @@ public final class AdPlaybackState {
|
|||||||
return C.INDEX_UNSET;
|
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
|
@CheckResult
|
||||||
private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) {
|
private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) {
|
||||||
int oldStateCount = states.length;
|
int oldStateCount = states.length;
|
||||||
@ -944,6 +959,20 @@ public final class AdPlaybackState {
|
|||||||
adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount);
|
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.
|
* @deprecated Use {@link #withAvailableAdMediaItem} instead.
|
||||||
*/
|
*/
|
||||||
|
@ -26,6 +26,7 @@ import static org.junit.Assert.fail;
|
|||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
@ -1201,4 +1202,65 @@ public class AdPlaybackStateTest {
|
|||||||
|
|
||||||
assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 3).durationsUs).hasLength(0);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,7 @@ import static java.lang.Math.min;
|
|||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.media3.common.AdPlaybackState;
|
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
|
* 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.
|
* sources have a different ads IDs.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("PatternMatchingInstanceof")
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public final class HlsInterstitialsAdsLoader implements AdsLoader {
|
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}.
|
||||||
|
*
|
||||||
|
* <p>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.
|
* 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<Object, AdPlaybackState> activeAdPlaybackStates;
|
private final Map<Object, AdPlaybackState> activeAdPlaybackStates;
|
||||||
private final Map<Object, Set<String>> insertedInterstitialIds;
|
private final Map<Object, Set<String>> insertedInterstitialIds;
|
||||||
private final Map<Object, TreeMap<Long, AssetListData>> unresolvedAssetLists;
|
private final Map<Object, TreeMap<Long, AssetListData>> unresolvedAssetLists;
|
||||||
|
private final Map<Object, AdPlaybackState> resumptionStates;
|
||||||
private final List<Listener> listeners;
|
private final List<Listener> listeners;
|
||||||
private final Set<Object> unsupportedAdsIds;
|
private final Set<Object> unsupportedAdsIds;
|
||||||
|
|
||||||
@ -504,6 +568,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
|
|||||||
activeAdPlaybackStates = new HashMap<>();
|
activeAdPlaybackStates = new HashMap<>();
|
||||||
insertedInterstitialIds = new HashMap<>();
|
insertedInterstitialIds = new HashMap<>();
|
||||||
unresolvedAssetLists = new HashMap<>();
|
unresolvedAssetLists = new HashMap<>();
|
||||||
|
resumptionStates = new HashMap<>();
|
||||||
listeners = new ArrayList<>();
|
listeners = new ArrayList<>();
|
||||||
unsupportedAdsIds = new HashSet<>();
|
unsupportedAdsIds = new HashSet<>();
|
||||||
}
|
}
|
||||||
@ -553,6 +618,99 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
|
|||||||
throw new IllegalArgumentException();
|
throw new IllegalArgumentException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the resumption states of the currently active {@link AdsMediaSource ads media sources}.
|
||||||
|
*
|
||||||
|
* <p>Call this method to get the resumption states before releasing the player and {@linkplain
|
||||||
|
* #addAdResumptionState(AdsResumptionState) resume at the same state later}.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>See {@link HlsInterstitialsAdsLoader.Listener#onStop(MediaItem, Object, AdPlaybackState)}
|
||||||
|
* and {@link #addAdResumptionState(Object, AdPlaybackState)} also.
|
||||||
|
*/
|
||||||
|
public ImmutableList<AdsResumptionState> getAdsResumptionStates() {
|
||||||
|
ImmutableList.Builder<AdsResumptionState> 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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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
|
@Override
|
||||||
public void start(
|
public void start(
|
||||||
AdsMediaSource adsMediaSource,
|
AdsMediaSource adsMediaSource,
|
||||||
@ -578,14 +736,19 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
|
|||||||
activeEventListeners.put(adsId, eventListener);
|
activeEventListeners.put(adsId, eventListener);
|
||||||
MediaItem mediaItem = adsMediaSource.getMediaItem();
|
MediaItem mediaItem = adsMediaSource.getMediaItem();
|
||||||
if (isHlsMediaItem(mediaItem)) {
|
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<>());
|
insertedInterstitialIds.put(adsId, new HashSet<>());
|
||||||
unresolvedAssetLists.put(adsId, new TreeMap<>());
|
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));
|
notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider));
|
||||||
} else {
|
} else {
|
||||||
putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId));
|
|
||||||
Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId);
|
Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId);
|
||||||
|
putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId));
|
||||||
unsupportedAdsIds.add(adsId);
|
unsupportedAdsIds.add(adsId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -714,6 +877,12 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isReleased && !unsupportedAdsIds.contains(adsId)) {
|
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(
|
notifyListeners(
|
||||||
listener ->
|
listener ->
|
||||||
listener.onStop(
|
listener.onStop(
|
||||||
@ -740,6 +909,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
|
|||||||
if (activeEventListeners.isEmpty()) {
|
if (activeEventListeners.isEmpty()) {
|
||||||
player = null;
|
player = null;
|
||||||
}
|
}
|
||||||
|
clearAllAdResumptionStates();
|
||||||
cancelPendingAssetListResolutionMessage();
|
cancelPendingAssetListResolutionMessage();
|
||||||
if (loader != null) {
|
if (loader != null) {
|
||||||
loader.release();
|
loader.release();
|
||||||
|
@ -53,6 +53,7 @@ import androidx.media3.datasource.DataSpec;
|
|||||||
import androidx.media3.exoplayer.ExoPlaybackException;
|
import androidx.media3.exoplayer.ExoPlaybackException;
|
||||||
import androidx.media3.exoplayer.ExoPlayer;
|
import androidx.media3.exoplayer.ExoPlayer;
|
||||||
import androidx.media3.exoplayer.PlayerMessage;
|
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.Asset;
|
||||||
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList;
|
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList;
|
||||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
|
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.DefaultMediaSourceFactory;
|
||||||
import androidx.media3.exoplayer.source.ads.AdsLoader;
|
import androidx.media3.exoplayer.source.ads.AdsLoader;
|
||||||
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
|
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;
|
||||||
import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition;
|
import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition;
|
||||||
import androidx.test.core.app.ApplicationProvider;
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
@ -256,6 +258,270 @@ public class HlsInterstitialsAdsLoaderTest {
|
|||||||
verifyNoMoreInteractions(mockAdsLoaderListener);
|
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<AdPlaybackState> 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<AdPlaybackState> 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> 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
|
@Test
|
||||||
public void handleContentTimelineChanged_preMidAndPostRolls_translatedToAdPlaybackState()
|
public void handleContentTimelineChanged_preMidAndPostRolls_translatedToAdPlaybackState()
|
||||||
throws IOException {
|
throws IOException {
|
||||||
@ -3409,6 +3675,16 @@ public class HlsInterstitialsAdsLoaderTest {
|
|||||||
verifyNoMoreInteractions(mockPlayer);
|
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
|
@Test
|
||||||
public void release_afterStartButBeforeStopped_playerListenerRemovedAfterAllSourcesStopped() {
|
public void release_afterStartButBeforeStopped_playerListenerRemovedAfterAllSourcesStopped() {
|
||||||
when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition));
|
when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition));
|
||||||
@ -3700,6 +3976,29 @@ public class HlsInterstitialsAdsLoaderTest {
|
|||||||
verifyNoMoreInteractions(mockEventListener);
|
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<AdPlaybackState> callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
|
private List<AdPlaybackState> callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
|
||||||
HlsInterstitialsAdsLoader adsLoader,
|
HlsInterstitialsAdsLoader adsLoader,
|
||||||
boolean startAdsLoader,
|
boolean startAdsLoader,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user