diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java
index 0555f37be9..0c7a6b27a5 100644
--- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java
+++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylist.java
@@ -15,6 +15,9 @@
*/
package androidx.media3.exoplayer.hls.playlist;
+import static androidx.media3.common.util.Assertions.checkArgument;
+import static androidx.media3.common.util.Assertions.checkNotNull;
+import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.annotation.ElementType.TYPE_USE;
@@ -22,6 +25,7 @@ import static java.lang.annotation.ElementType.TYPE_USE;
import android.net.Uri;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
import androidx.media3.common.C;
import androidx.media3.common.DrmInitData;
import androidx.media3.common.StreamKey;
@@ -36,6 +40,7 @@ import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
/** Represents an HLS media playlist. */
@UnstableApi
@@ -372,7 +377,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
/**
* A rendition report for an alternative rendition defined in another media playlist.
*
- *
See RFC 8216, section 4.4.5.1.4.
+ *
See RFC 8216bis, section 4.4.5.1.4.
*/
public static final class RenditionReport {
/** The URI of the media playlist of the reported rendition. */
@@ -401,6 +406,303 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
}
}
+ /**
+ * An interstitial data range.
+ *
+ *
See RFC 8216bis, appendix D.2.
+ */
+ public static final class Interstitial {
+
+ /**
+ * The cue trigger type. One of {@link #CUE_TRIGGER_PRE}, {@link #CUE_TRIGGER_POST} or {@link
+ * #CUE_TRIGGER_ONCE}.
+ *
+ *
See RFC 8216bis, section 4.4.5.1.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({CUE_TRIGGER_PRE, CUE_TRIGGER_POST, CUE_TRIGGER_ONCE})
+ @Documented
+ @Target(TYPE_USE)
+ public @interface CueTriggerType {}
+
+ /**
+ * Cue trigger type indicating to trigger the interstitial before playback of the primary asset.
+ */
+ public static final String CUE_TRIGGER_PRE = "PRE";
+
+ /**
+ * Cue trigger type indicating to trigger the interstitial after playback of the primary asset.
+ */
+ public static final String CUE_TRIGGER_POST = "POST";
+
+ /** Cue trigger type indicating to trigger the interstitial only once. */
+ public static final String CUE_TRIGGER_ONCE = "ONCE";
+
+ /**
+ * The snap identifier. One of {@link #SNAP_TYPE_IN} or {@link #SNAP_TYPE_OUT}.
+ *
+ *
See RFC 8216bis, appendix D.2.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({SNAP_TYPE_IN, SNAP_TYPE_OUT})
+ @Documented
+ @Target(TYPE_USE)
+ public @interface SnapType {}
+
+ /**
+ * Snap identifier indicating to locate the segment boundary closest to the scheduled resumption
+ * point of the interstitial.
+ */
+ public static final String SNAP_TYPE_IN = "IN";
+
+ /**
+ * Snap identifier indicating to locate the segment boundary closest to the {@link
+ * Interstitial#startDateUnixUs}.
+ */
+ public static final String SNAP_TYPE_OUT = "OUT";
+
+ /**
+ * The navigation restriction identifier. One of {@link #NAVIGATION_RESTRICTION_JUMP} or {@link
+ * #NAVIGATION_RESTRICTION_SKIP}.
+ *
+ *
See RFC 8216bis, appendix D.2.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({NAVIGATION_RESTRICTION_SKIP, NAVIGATION_RESTRICTION_JUMP})
+ @Documented
+ @Target(TYPE_USE)
+ public @interface NavigationRestriction {}
+
+ /**
+ * Navigation restriction identifier indicating to prevent seeking or changing the playback
+ * speed during the interstitial being played.
+ */
+ public static final String NAVIGATION_RESTRICTION_SKIP = "SKIP";
+
+ /**
+ * Navigation restriction identifier indicating to enforce playback of the interstitial if the
+ * user attempts to seek beyond the interstitial start position.
+ */
+ public static final String NAVIGATION_RESTRICTION_JUMP = "JUMP";
+
+ /** The required ID. */
+ public final String id;
+
+ /** The asset URI. Required if {@link #assetListUri} is null. */
+ @Nullable public final Uri assetUri;
+
+ /** The asset list URI. Required if {@link #assetUri} is null. */
+ @Nullable public final Uri assetListUri;
+
+ /** The required start time, in microseconds. */
+ public final long startDateUnixUs;
+
+ /** The optional end time, in microseconds. {@link C#TIME_UNSET} if not present. */
+ public final long endDateUnixUs;
+
+ /** The optional duration, in microseconds. {@link C#TIME_UNSET} if not present. */
+ public final long durationUs;
+
+ /** The optional planned duration, in microseconds. {@link C#TIME_UNSET} if not present. */
+ public final long plannedDurationUs;
+
+ /** The trigger cue types. */
+ public final List<@CueTriggerType String> cue;
+
+ /**
+ * Whether the {@link #endDateUnixUs} of the interstitial is equal to the start {@link
+ * #startTimeUs} of the following interstitial. {@code false} if not present.
+ */
+ public final boolean endOnNext;
+
+ /**
+ * The offset from {@link #startTimeUs} indicating where in the primary asset to resume playback
+ * after completing playback of the interstitial. {@link C#TIME_UNSET} if not present. If not
+ * present, the value is considered to be the duration of the interstitial.
+ */
+ public final long resumeOffsetUs;
+
+ /** The playout limit indicating the limit of the playback time of the interstitial. */
+ public final long playoutLimitUs;
+
+ /** The snap types. */
+ public final ImmutableList<@SnapType String> snapTypes;
+
+ /** The navigation restrictions. */
+ public final ImmutableList<@NavigationRestriction String> restrictions;
+
+ /** The attributes defined by a client. For informational purpose only. */
+ public final ImmutableList clientDefinedAttributes;
+
+ /** Creates an instance. */
+ public Interstitial(
+ String id,
+ @Nullable Uri assetUri,
+ @Nullable Uri assetListUri,
+ long startDateUnixUs,
+ long endDateUnixUs,
+ long durationUs,
+ long plannedDurationUs,
+ List<@CueTriggerType String> cue,
+ boolean endOnNext,
+ long resumeOffsetUs,
+ long playoutLimitUs,
+ List<@SnapType String> snapTypes,
+ List<@NavigationRestriction String> restrictions,
+ List clientDefinedAttributes) {
+ checkArgument(
+ (assetUri == null || assetListUri == null) && (assetUri != null || assetListUri != null));
+ this.id = id;
+ this.assetUri = assetUri;
+ this.assetListUri = assetListUri;
+ this.startDateUnixUs = startDateUnixUs;
+ this.endDateUnixUs = endDateUnixUs;
+ this.durationUs = durationUs;
+ this.plannedDurationUs = plannedDurationUs;
+ this.cue = cue;
+ this.endOnNext = endOnNext;
+ this.resumeOffsetUs = resumeOffsetUs;
+ this.playoutLimitUs = playoutLimitUs;
+ this.snapTypes = ImmutableList.copyOf(snapTypes);
+ this.restrictions = ImmutableList.copyOf(restrictions);
+ this.clientDefinedAttributes = ImmutableList.copyOf(clientDefinedAttributes);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Interstitial)) {
+ return false;
+ }
+ Interstitial that = (Interstitial) o;
+ return startDateUnixUs == that.startDateUnixUs
+ && endDateUnixUs == that.endDateUnixUs
+ && durationUs == that.durationUs
+ && plannedDurationUs == that.plannedDurationUs
+ && endOnNext == that.endOnNext
+ && resumeOffsetUs == that.resumeOffsetUs
+ && playoutLimitUs == that.playoutLimitUs
+ && Objects.equals(id, that.id)
+ && Objects.equals(assetUri, that.assetUri)
+ && Objects.equals(assetListUri, that.assetListUri)
+ && Objects.equals(cue, that.cue)
+ && Objects.equals(snapTypes, that.snapTypes)
+ && Objects.equals(restrictions, that.restrictions)
+ && Objects.equals(clientDefinedAttributes, that.clientDefinedAttributes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ id,
+ assetUri,
+ assetListUri,
+ startDateUnixUs,
+ endDateUnixUs,
+ durationUs,
+ plannedDurationUs,
+ cue,
+ endOnNext,
+ resumeOffsetUs,
+ playoutLimitUs,
+ snapTypes,
+ restrictions,
+ clientDefinedAttributes);
+ }
+ }
+
+ /** A client defined attribute. See RFC 8216bis, section 4.4.5.1. */
+ public static class ClientDefinedAttribute {
+
+ /**
+ * The type of the client defined attribute. One of {@link #TYPE_TEXT}, {@link #TYPE_HEX_TEXT}
+ * or {@link #TYPE_DOUBLE}.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_DOUBLE, TYPE_HEX_TEXT, TYPE_TEXT})
+ @Documented
+ @Target(TYPE_USE)
+ public @interface Type {}
+
+ /** Type text. See RFC 8216bis, section 4.2, quoted-string. */
+ public static final int TYPE_TEXT = 0;
+
+ /** Type hex text. See RFC 8216bis, section 4.2, hexadecimal-sequence. */
+ public static final int TYPE_HEX_TEXT = 1;
+
+ /** Type double. See RFC 8216bis, section 4.2, decimal-floating-point. */
+ public static final int TYPE_DOUBLE = 2;
+
+ /** The name of the client defined attribute. */
+ public final String name;
+
+ /** The type of the client defined attribute. */
+ public final int type;
+
+ private final double doubleValue;
+ @Nullable private final String textValue;
+
+ /** Creates an instance of type {@link #TYPE_DOUBLE}. */
+ public ClientDefinedAttribute(String name, double value) {
+ this.name = name;
+ this.type = TYPE_DOUBLE;
+ this.doubleValue = value;
+ textValue = null;
+ }
+
+ /** Creates an instance of type {@link #TYPE_TEXT} or {@link #TYPE_HEX_TEXT}. */
+ public ClientDefinedAttribute(String name, String value, @Type int type) {
+ checkState(type != TYPE_HEX_TEXT || value.startsWith("0x") || value.startsWith("0X"));
+ this.name = name;
+ this.type = type;
+ this.textValue = value;
+ doubleValue = 0.0d;
+ }
+
+ /**
+ * Returns the value if the attribute is of {@link #TYPE_DOUBLE}.
+ *
+ * @throws IllegalStateException if the attribute is not of type {@link #TYPE_TEXT} or {@link
+ * #TYPE_HEX_TEXT}.
+ */
+ public double getDoubleValue() {
+ checkState(type == TYPE_DOUBLE);
+ return doubleValue;
+ }
+
+ /**
+ * Returns the text value if the attribute is of {@link #TYPE_TEXT} or {@link #TYPE_HEX_TEXT}.
+ *
+ * @throws IllegalStateException if the attribute is not of type {@link #TYPE_DOUBLE}.
+ */
+ public String getTextValue() {
+ checkState(type != TYPE_DOUBLE);
+ return checkNotNull(textValue);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ClientDefinedAttribute)) {
+ return false;
+ }
+ ClientDefinedAttribute that = (ClientDefinedAttribute) o;
+ return type == that.type
+ && Double.compare(doubleValue, that.doubleValue) == 0
+ && Objects.equals(name, that.name)
+ && Objects.equals(textValue, that.textValue);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, type, doubleValue, textValue);
+ }
+ }
+
/**
* Type of the playlist, as defined by #EXT-X-PLAYLIST-TYPE. One of {@link
* #PLAYLIST_TYPE_UNKNOWN}, {@link #PLAYLIST_TYPE_VOD} or {@link #PLAYLIST_TYPE_EVENT}.
@@ -497,6 +799,12 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
/** The attributes of the #EXT-X-SERVER-CONTROL header. */
public final ServerControl serverControl;
+ /**
+ * The interstitials declared as {@code #EXT-X-DATERANGE} with {@code
+ * CLASS="com.apple.hls.interstitial"}
+ */
+ public final ImmutableList interstitials;
+
/**
* Constructs an instance.
*
@@ -520,6 +828,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
* @param trailingParts See {@link #trailingParts}.
* @param serverControl See {@link #serverControl}
* @param renditionReports See {@link #renditionReports}.
+ * @param interstitials See {@link #interstitials}.
*/
public HlsMediaPlaylist(
@PlaylistType int playlistType,
@@ -541,7 +850,8 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
List segments,
List trailingParts,
ServerControl serverControl,
- Map renditionReports) {
+ Map renditionReports,
+ List interstitials) {
super(baseUri, tags, hasIndependentSegments);
this.playlistType = playlistType;
this.startTimeUs = startTimeUs;
@@ -558,6 +868,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
this.segments = ImmutableList.copyOf(segments);
this.trailingParts = ImmutableList.copyOf(trailingParts);
this.renditionReports = ImmutableMap.copyOf(renditionReports);
+ this.interstitials = ImmutableList.copyOf(interstitials);
if (!trailingParts.isEmpty()) {
Part lastPart = Iterables.getLast(trailingParts);
durationUs = lastPart.relativeStartTimeUs + lastPart.durationUs;
@@ -567,7 +878,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
} else {
durationUs = 0;
}
- // From RFC 8216, section 4.4.2.2: If startOffsetUs is negative, it indicates the offset from
+ // From RFC 8216bis, section 4.4.2.2: If startOffsetUs is negative, it indicates the offset from
// the end of the playlist. If the absolute value exceeds the duration of the playlist, it
// indicates the beginning (if negative) or the end (if positive) of the playlist.
this.startOffsetUs =
@@ -644,7 +955,8 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
segments,
trailingParts,
serverControl,
- renditionReports);
+ renditionReports,
+ interstitials);
}
/**
@@ -675,6 +987,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist {
segments,
trailingParts,
serverControl,
- renditionReports);
+ renditionReports,
+ interstitials);
}
}
diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java
index 8dc60984f2..861e1f326f 100644
--- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java
+++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistParser.java
@@ -18,6 +18,11 @@ package androidx.media3.exoplayer.hls.playlist;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Util.castNonNull;
+import static androidx.media3.common.util.Util.msToUs;
+import static androidx.media3.common.util.Util.parseXsDateTime;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_ONCE;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE;
import android.net.Uri;
import android.text.TextUtils;
@@ -37,6 +42,7 @@ import androidx.media3.common.util.UriUtil;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.hls.HlsTrackMetadataEntry;
import androidx.media3.exoplayer.hls.HlsTrackMetadataEntry.VariantInfo;
+import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Part;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.RenditionReport;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Segment;
@@ -44,6 +50,7 @@ import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Rendition;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Variant;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import androidx.media3.extractor.mp4.PsshAtomUtil;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.io.BufferedReader;
import java.io.IOException;
@@ -105,6 +112,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser renditionReports = new ArrayList<>();
List tags = new ArrayList<>();
+ List interstitials = new ArrayList<>();
long segmentDurationUs = 0;
String segmentTitle = "";
@@ -848,7 +877,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser cue = new ArrayList<>();
+ String cueString = parseOptionalStringAttr(line, REGEX_CUE, variableDefinitions);
+ if (cueString != null) {
+ String[] identifiers = Util.split(/* value= */ cueString, /* regex= */ ",");
+ for (String identifier : identifiers) {
+ identifier = identifier.trim();
+ switch (identifier) {
+ case CUE_TRIGGER_ONCE:
+ case CUE_TRIGGER_POST:
+ case CUE_TRIGGER_PRE:
+ cue.add(identifier);
+ break;
+ default:
+ break; // ignore for forward compatibility
+ }
+ }
+ }
+ double durationSec = parseOptionalDoubleAttr(line, REGEX_ATTR_DURATION, -1.0d);
+ long durationUs = C.TIME_UNSET;
+ if (durationSec >= 0) {
+ durationUs = (long) (durationSec * C.MICROS_PER_SECOND);
+ }
+ double plannedDurationSec = parseOptionalDoubleAttr(line, REGEX_PLANNED_DURATION, -1.0d);
+ long plannedDurationUs = C.TIME_UNSET;
+ if (plannedDurationSec >= 0) {
+ plannedDurationUs = (long) (plannedDurationSec * C.MICROS_PER_SECOND);
+ }
+ boolean endOnNext = parseOptionalBooleanAttribute(line, REGEX_END_ON_NEXT, false);
+ double resumeOffsetUsDouble =
+ parseOptionalDoubleAttr(line, REGEX_RESUME_OFFSET, Double.MIN_VALUE);
+ long resumeOffsetUs = C.TIME_UNSET;
+ if (resumeOffsetUsDouble != Double.MIN_VALUE) {
+ resumeOffsetUs = (long) (resumeOffsetUsDouble * C.MICROS_PER_SECOND);
+ }
+ double playoutLimitSec = parseOptionalDoubleAttr(line, REGEX_PLAYOUT_LIMIT, -1.0d);
+ long playoutLimitUs = C.TIME_UNSET;
+ if (playoutLimitSec >= 0) {
+ playoutLimitUs = (long) (playoutLimitSec * C.MICROS_PER_SECOND);
+ }
+ List<@Interstitial.SnapType String> snapTypes = new ArrayList<>();
+ String snapTypesString = parseOptionalStringAttr(line, REGEX_SNAP, variableDefinitions);
+ if (snapTypesString != null) {
+ String[] snapTypesSplit = Util.split(snapTypesString, ",");
+ for (String snapType : snapTypesSplit) {
+ snapType = snapType.trim();
+ switch (snapType) {
+ case Interstitial.SNAP_TYPE_IN:
+ case Interstitial.SNAP_TYPE_OUT:
+ snapTypes.add(snapType);
+ break;
+ default:
+ break; // ignore for forward compatibility
+ }
+ }
+ }
+ List<@Interstitial.NavigationRestriction String> restrictions = new ArrayList<>();
+ String restrictionsString =
+ parseOptionalStringAttr(line, REGEX_RESTRICT, variableDefinitions);
+ if (restrictionsString != null) {
+ String[] restrictionsSplit = Util.split(restrictionsString, ",");
+ for (String restriction : restrictionsSplit) {
+ restriction = restriction.trim();
+ switch (restriction) {
+ case Interstitial.NAVIGATION_RESTRICTION_JUMP:
+ case Interstitial.NAVIGATION_RESTRICTION_SKIP:
+ restrictions.add(restriction);
+ break;
+ default:
+ break; // ignore for forward compatibility
+ }
+ }
+ }
+
+ ImmutableList.Builder clientDefinedAttributes =
+ new ImmutableList.Builder<>();
+ String attributes = line.substring("#EXT-X-DATERANGE:".length());
+ Matcher matcher = REGEX_CLIENT_DEFINED_ATTRIBUTE_PREFIX.matcher(attributes);
+ while (matcher.find()) {
+ String attributePrefix = matcher.group();
+ switch (attributePrefix) {
+ case "X-ASSET-URI=": // fall through
+ case "X-ASSET-LIST=": // fall through
+ case "X-RESUME-OFFSET=": // fall through
+ case "X-PLAYOUT-LIMIT=": // fall through
+ case "X-SNAP=": // fall through
+ case "X-RESTRICT=": // fall through
+ // ignore interstitial attributes
+ break;
+ default:
+ clientDefinedAttributes.add(
+ parseClientDefinedAttribute(
+ attributes,
+ attributePrefix.substring(0, attributePrefix.length() - 1),
+ variableDefinitions));
+ break;
+ }
+ }
+ interstitials.add(
+ new Interstitial(
+ id,
+ assetUri,
+ assetListUri,
+ startDateUnixUs,
+ endDateUnixUs,
+ durationUs,
+ plannedDurationUs,
+ cue,
+ endOnNext,
+ resumeOffsetUs,
+ playoutLimitUs,
+ snapTypes,
+ restrictions,
+ clientDefinedAttributes.build()));
} else if (!line.startsWith("#")) {
@Nullable
String segmentEncryptionIV =
@@ -1062,7 +1234,8 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions)
+ throws ParserException {
+ String prefix = clientAttribute + "=";
+ int index = attributes.indexOf(prefix);
+ int valueBeginIndex = index + prefix.length();
+ int valueBeginMaxLength = attributes.length() == valueBeginIndex + 1 ? 1 : 2;
+ String valueBegin =
+ attributes.substring(valueBeginIndex, valueBeginIndex + valueBeginMaxLength);
+ if (valueBegin.startsWith("\"")) {
+ // a quoted string value
+ Pattern pattern = Pattern.compile(clientAttribute + "=\"(.+?)\"");
+ String value = parseStringAttr(attributes, pattern, variableDefinitions);
+ return new HlsMediaPlaylist.ClientDefinedAttribute(
+ clientAttribute, value, HlsMediaPlaylist.ClientDefinedAttribute.TYPE_TEXT);
+ } else if (valueBegin.equals("0x") || valueBegin.equals("0X")) {
+ // a hexadecimal sequence value
+ Pattern pattern = Pattern.compile(clientAttribute + "=(0[xX][A-F0-9]+)");
+ String value = parseStringAttr(attributes, pattern, variableDefinitions);
+ return new HlsMediaPlaylist.ClientDefinedAttribute(
+ clientAttribute, value, HlsMediaPlaylist.ClientDefinedAttribute.TYPE_HEX_TEXT);
+ } else {
+ // a decimal-floating-point value
+ Pattern pattern = Pattern.compile(clientAttribute + "=([\\d\\.]+)\\b");
+ return new HlsMediaPlaylist.ClientDefinedAttribute(
+ clientAttribute, parseDoubleAttr(attributes, pattern));
+ }
+ }
+
private static String replaceVariableReferences(
String string, Map variableDefinitions) {
Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string);
diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java
index ddfbb908a3..af603704fa 100644
--- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java
+++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistParserTest.java
@@ -15,7 +15,16 @@
*/
package androidx.media3.exoplayer.hls.playlist;
+import static androidx.media3.common.util.Util.msToUs;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_ONCE;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.NAVIGATION_RESTRICTION_JUMP;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.NAVIGATION_RESTRICTION_SKIP;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.SNAP_TYPE_IN;
+import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.SNAP_TYPE_OUT;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
import static org.junit.Assert.fail;
import android.net.Uri;
@@ -24,13 +33,18 @@ import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.Util;
+import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Segment;
import androidx.media3.extractor.mp4.PsshAtomUtil;
+import androidx.media3.test.utils.TestUtil;
+import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.text.ParseException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -1104,6 +1118,405 @@ public class HlsMediaPlaylistParserTest {
assertThat(report0.lastPartIndex).isEqualTo(3);
}
+ @Test
+ public void parseMediaPlaylist_withInterstitialDateRanges() throws IOException, ParseException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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:"
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "ID=\"ad1\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ + "END-DATE=\"2020-01-02T21:55:59.000Z\","
+ + "DURATION=15.0,"
+ + "PLANNED-DURATION=15.13,"
+ + "CUE=\" PRE \","
+ + "END-ON-NEXT=YES, "
+ + "X-ASSET-URI=\"http://example.com/ad1.m3u8\","
+ + "X-RESUME-OFFSET=-1.1, "
+ + "X-PLAYOUT-LIMIT=15.112, "
+ + "X-SNAP=\" IN , OUT \", "
+ + "X-RESTRICT=\"SKIP , JUMP\","
+ + "X-GOOGLE-TEST-TEXT=\"Value at the end\""
+ + "\n"
+ + "#EXT-X-DATERANGE:"
+ + "X-GOOGLE-TEST-HEX=0XAB10A,"
+ + "ID=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2020-01-02T22:55:44.000Z\","
+ + "END-DATE=\"2020-01-02T22:55:49.120Z\","
+ + "DURATION=5.12,"
+ + "PLANNED-DURATION=5.11,"
+ + "CUE=\" POST, ONCE\","
+ + "X-GOOGLE-TEST-DOUBLE1=12.123,"
+ + "X-ASSET-LIST=\"http://example.com/ad2-assets.json\""
+ + "X-GOOGLE-TEST-DOUBLE2=1\n";
+ long playlistStartTimeUs = msToUs(Util.parseXsDateTime("2020-01-02T21:55:40.000Z"));
+ Interstitial interstitial1 =
+ new Interstitial(
+ /* id= */ "ad1",
+ /* assetUri= */ Uri.parse("http://example.com/ad1.m3u8"),
+ /* assetListUri= */ null,
+ /* startDateUnixUs= */ playlistStartTimeUs + 4_000_000L,
+ /* endDateUnixUs= */ playlistStartTimeUs + 4_000_000L + 15_000_000L,
+ /* durationUs= */ 15_000_000L,
+ /* plannedDurationUs= */ 15_130_000L,
+ /* cue= */ ImmutableList.of(CUE_TRIGGER_PRE),
+ /* endOnNext= */ true,
+ /* resumeOffsetUs= */ -1_100_000L,
+ /* playoutLimitUs= */ 15_112_000L,
+ ImmutableList.of(SNAP_TYPE_IN, SNAP_TYPE_OUT),
+ ImmutableList.of(NAVIGATION_RESTRICTION_SKIP, NAVIGATION_RESTRICTION_JUMP),
+ /* clientDefinedAttributes= */ ImmutableList.of(
+ new HlsMediaPlaylist.ClientDefinedAttribute(
+ "X-GOOGLE-TEST-TEXT",
+ "Value at the end",
+ HlsMediaPlaylist.ClientDefinedAttribute.TYPE_TEXT)));
+ Interstitial interstitial2 =
+ new Interstitial(
+ /* id= */ "ad2",
+ /* assetUri= */ null,
+ /* assetListUri= */ Uri.parse("http://example.com/ad2-assets.json"),
+ /* startDateUnixUs= */ playlistStartTimeUs + 4_000_000L + (60L * 60L * 1_000_000L),
+ /* endDateUnixUs= */ playlistStartTimeUs
+ + 4_000_000L
+ + 5_120_000L
+ + (60L * 60L * 1_000_000L),
+ /* durationUs= */ 5_120_000L,
+ /* plannedDurationUs= */ 5_110_000L,
+ ImmutableList.of(CUE_TRIGGER_POST, CUE_TRIGGER_ONCE),
+ /* endOnNext= */ false,
+ /* resumeOffsetUs= */ C.TIME_UNSET,
+ /* playoutLimitUs= */ C.TIME_UNSET,
+ /* snapTypes= */ ImmutableList.of(),
+ /* restrictions= */ ImmutableList.of(),
+ /* clientDefinedAttributes= */ ImmutableList.of(
+ new HlsMediaPlaylist.ClientDefinedAttribute(
+ "X-GOOGLE-TEST-HEX",
+ "0XAB10A",
+ HlsMediaPlaylist.ClientDefinedAttribute.TYPE_HEX_TEXT),
+ new HlsMediaPlaylist.ClientDefinedAttribute("X-GOOGLE-TEST-DOUBLE1", 12.123d),
+ new HlsMediaPlaylist.ClientDefinedAttribute("X-GOOGLE-TEST-DOUBLE2", 1d)));
+ InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString));
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream);
+
+ assertThat(playlist.interstitials).containsExactly(interstitial1, interstitial2).inOrder();
+ }
+
+ @Test
+ public void parseMediaPlaylist_withInterstitialsVodWithAssetUriOnly_correctlyParseAllAttributes()
+ throws IOException {
+ Uri playlistUri = Uri.parse("http://example.com/interstitials_vod_asset_uri.m3u8");
+ InputStream inputStream =
+ TestUtil.getInputStream(
+ ApplicationProvider.getApplicationContext(),
+ "media/hls/interstitials/interstitials_vod_asset_uri.m3u8");
+ Interstitial interstitial1 =
+ new Interstitial(
+ "preroll",
+ /* assetUri= */ Uri.parse("https://example.com/0.m3u8?parse=1"),
+ /* assetListUri= */ null,
+ /* startDateUnixUs= */ msToUs(Util.parseXsDateTime("2024-11-11T04:35:19.353-08:00")),
+ /* endDateUnixUs= */ C.TIME_UNSET,
+ /* durationUs= */ C.TIME_UNSET,
+ /* plannedDurationUs= */ C.TIME_UNSET,
+ /* cue= */ ImmutableList.of("PRE"),
+ /* endOnNext= */ false,
+ /* resumeOffsetUs= */ 0L,
+ /* playoutLimitUs= */ C.TIME_UNSET,
+ /* snapTypes= */ ImmutableList.of(),
+ /* restrictions= */ ImmutableList.of("JUMP", "SKIP"),
+ /* clientDefinedAttributes= */ ImmutableList.of());
+ Interstitial interstitial2 =
+ new Interstitial(
+ "midroll-1",
+ /* assetUri= */ Uri.parse("https://example.com/1.m3u8?parse=1"),
+ /* assetListUri= */ null,
+ /* startDateUnixUs= */ msToUs(Util.parseXsDateTime("2024-11-11T04:35:34.353-08:00")),
+ /* endDateUnixUs= */ C.TIME_UNSET,
+ /* durationUs= */ C.TIME_UNSET,
+ /* plannedDurationUs= */ C.TIME_UNSET,
+ /* cue= */ ImmutableList.of(),
+ /* endOnNext= */ false,
+ /* resumeOffsetUs= */ 0L,
+ /* playoutLimitUs= */ C.TIME_UNSET,
+ /* snapTypes= */ ImmutableList.of(),
+ /* restrictions= */ ImmutableList.of("JUMP", "SKIP"),
+ /* clientDefinedAttributes= */ ImmutableList.of());
+ Interstitial interstitial3 =
+ new Interstitial(
+ "postroll",
+ /* assetUri= */ Uri.parse("https://example.com/2.m3u8?parse=1"),
+ /* assetListUri= */ null,
+ /* startDateUnixUs= */ msToUs(Util.parseXsDateTime("2024-11-11T04:36:31.353-08:00")),
+ /* endDateUnixUs= */ C.TIME_UNSET,
+ /* durationUs= */ C.TIME_UNSET,
+ /* plannedDurationUs= */ C.TIME_UNSET,
+ /* cue= */ ImmutableList.of("POST"),
+ /* endOnNext= */ false,
+ /* resumeOffsetUs= */ 0L,
+ /* playoutLimitUs= */ C.TIME_UNSET,
+ /* snapTypes= */ ImmutableList.of(),
+ /* restrictions= */ ImmutableList.of(
+ NAVIGATION_RESTRICTION_JUMP, NAVIGATION_RESTRICTION_SKIP),
+ /* clientDefinedAttributes= */ ImmutableList.of());
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream);
+
+ assertThat(playlist.interstitials)
+ .containsExactly(interstitial1, interstitial2, interstitial3)
+ .inOrder();
+ assertThat(playlist.startTimeUs)
+ .isEqualTo(msToUs(Util.parseXsDateTime("2024-11-11T04:35:19.353-08:00")));
+ }
+
+ @Test
+ public void parseMediaPlaylist_withInterstitialWithoutId_throwsParserException() {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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:"
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "X-ASSET-URI=\"http://example.com/ad1.m3u8\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+
+ assertThrows(
+ ParserException.class,
+ () ->
+ hlsPlaylistParser.parse(
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))));
+ }
+
+ @Test
+ public void parseMediaPlaylist_withDateRangeWithUnknownClass_dateRangeIgnored()
+ throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"some.other.class\","
+ + "X-ASSET-URI=\"http://example.com/ad1.m3u8\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ hlsPlaylistParser.parse(
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials).isEmpty();
+ }
+
+ @Test
+ public void parseMediaPlaylist_withDateRangeWithMissingClass_dateRangeIgnored()
+ throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "X-ASSET-URI=\"http://example.com/ad1.m3u8\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ hlsPlaylistParser.parse(
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials).isEmpty();
+ }
+
+ @Test
+ public void parseMediaPlaylist_withInterstitialWithoutStartDate_throwsParserException() {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "X-ASSET-LIST=\"http://example.com/ad2-assets.json\"\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+
+ assertThrows(
+ ParserException.class,
+ () ->
+ hlsPlaylistParser.parse(
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))));
+ }
+
+ @Test
+ public void parseMediaPlaylist_withInterstitialWithAssetUriAndList_throwsParserException() {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ + "X-ASSET-URI=\"http://example.com/media.m3u8\","
+ + "X-ASSET-LIST=\"http://example.com/ad2-assets.json\"\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+
+ assertThrows(
+ ParserException.class,
+ () ->
+ hlsPlaylistParser.parse(
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))));
+ }
+
+ @Test
+ public void
+ parseMediaPlaylist_withInterstitialWithNeitherAssetUriNorAssetList_throwsParserException() {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+ HlsPlaylistParser hlsPlaylistParser = new HlsPlaylistParser();
+
+ assertThrows(
+ ParserException.class,
+ () ->
+ hlsPlaylistParser.parse(
+ playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString))));
+ }
+
+ @Test
+ public void
+ parseMediaPlaylist_withInterstitialWithUnknownRestrictions_ignoredForForwardCompatibility()
+ throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "X-RESTRICT=\"SKIP , FLY , ROW , JUMP\","
+ + "X-ASSET-URI=\"http://example.com/media.m3u8\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ new HlsPlaylistParser()
+ .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials.get(0).restrictions)
+ .containsExactly(NAVIGATION_RESTRICTION_SKIP, NAVIGATION_RESTRICTION_JUMP)
+ .inOrder();
+ }
+
+ @Test
+ public void
+ parseMediaPlaylist_withInterstitialWithUnknownCueIdentifier_ignoredForForwardCompatibility()
+ throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "X-CUE=\"ALONE , PRE , INBETWEEN , BEYOND\","
+ + "X-ASSET-URI=\"http://example.com/media.m3u8\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ new HlsPlaylistParser()
+ .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials.get(0).cue).containsExactly(CUE_TRIGGER_PRE);
+ }
+
+ @Test
+ public void
+ parseMediaPlaylist_withInterstitialWithUnknownSnapTypes_ignoredForForwardCompatibility()
+ throws IOException {
+ Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
+ 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=\"ad2\","
+ + "CLASS=\"com.apple.hls.interstitial\","
+ + "X-SNAP=\" FOR , OUT , AT\", "
+ + "X-ASSET-URI=\"http://example.com/media.m3u8\","
+ + "START-DATE=\"2020-01-02T21:55:44.000Z\"\n";
+
+ HlsMediaPlaylist playlist =
+ (HlsMediaPlaylist)
+ new HlsPlaylistParser()
+ .parse(playlistUri, new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)));
+
+ assertThat(playlist.interstitials.get(0).snapTypes).containsExactly(SNAP_TYPE_OUT);
+ }
+
@Test
public void multipleExtXKeysForSingleSegment() throws Exception {
Uri playlistUri = Uri.parse("https://example.com/test.m3u8");
diff --git a/libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8 b/libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8
new file mode 100644
index 0000000000..e27e967fec
--- /dev/null
+++ b/libraries/test_data/src/test/assets/media/hls/interstitials/interstitials_vod_asset_uri.m3u8
@@ -0,0 +1,47 @@
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-TARGETDURATION:6
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-INDEPENDENT-SEGMENTS
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXT-X-BITRATE:4612
+#EXT-X-PROGRAM-DATE-TIME:2024-11-11T04:35:19.353-08:00
+#EXTINF:6,
+https://example.com/fileSequence0.ts
+#EXT-X-BITRATE:4609
+#EXTINF:6,
+https://example.com/fileSequence1.ts
+#EXT-X-BITRATE:4609
+#EXTINF:6,
+https://example.com/fileSequence2.ts
+#EXT-X-BITRATE:4611
+#EXTINF:6,
+https://example.com/fileSequence3.ts
+#EXT-X-BITRATE:4611
+#EXTINF:6,
+https://example.com/fileSequence4.ts
+#EXT-X-BITRATE:4609
+#EXTINF:6,
+https://example.com/fileSequence5.ts
+#EXT-X-BITRATE:4610
+#EXTINF:6,
+https://example.com/fileSequence6.ts
+#EXT-X-BITRATE:4610
+#EXTINF:6,
+https://example.com/fileSequence7.ts
+#EXT-X-BITRATE:4611
+#EXTINF:6,
+https://example.com/fileSequence8.ts
+#EXT-X-BITRATE:4609
+#EXTINF:6,
+https://example.com/fileSequence9.ts
+#EXT-X-BITRATE:4609
+#EXTINF:6,
+https://example.com/fileSequence10.ts
+#EXT-X-BITRATE:4611
+#EXTINF:6,
+https://example.com/fileSequence11.ts
+#EXT-X-DATERANGE:ID="preroll",START-DATE="2024-11-11T04:35:19.353-08:00",CLASS="com.apple.hls.interstitial",CUE="PRE",X-ASSET-URI="https://example.com/0.m3u8?parse=1",X-RESUME-OFFSET=0,X-RESTRICT="JUMP,SKIP"
+#EXT-X-DATERANGE:ID="midroll-1",START-DATE="2024-11-11T04:35:34.353-08:00",CLASS="com.apple.hls.interstitial",X-ASSET-URI="https://example.com/1.m3u8?parse=1",X-RESUME-OFFSET=0,X-RESTRICT="JUMP,SKIP"
+#EXT-X-DATERANGE:ID="postroll",START-DATE="2024-11-11T04:36:31.353-08:00",CLASS="com.apple.hls.interstitial",CUE="POST",X-ASSET-URI="https://example.com/2.m3u8?parse=1",X-RESUME-OFFSET=0,X-RESTRICT="JUMP,SKIP"
+#EXT-X-ENDLIST