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