summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls')
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java129
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java39
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java338
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java85
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java668
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java35
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java92
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java44
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java519
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java858
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java528
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java97
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java1535
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java245
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java30
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java57
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java195
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java148
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java33
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java678
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java55
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java330
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java375
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java50
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java1007
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java38
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java226
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java19
30 files changed, 8491 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java
new file mode 100644
index 0000000000..4643c0402c
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceInputStream;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.io.IOException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.AlgorithmParameterSpec;
+import java.util.List;
+import java.util.Map;
+import javax.crypto.Cipher;
+import javax.crypto.CipherInputStream;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * A {@link DataSource} that decrypts data read from an upstream source, encrypted with AES-128 with
+ * a 128-bit key and PKCS7 padding.
+ *
+ * <p>Note that this {@link DataSource} does not support being opened from arbitrary offsets. It is
+ * designed specifically for reading whole files as defined in an HLS media playlist. For this
+ * reason the implementation is private to the HLS package.
+ */
+/* package */ class Aes128DataSource implements DataSource {
+
+ private final DataSource upstream;
+ private final byte[] encryptionKey;
+ private final byte[] encryptionIv;
+
+ @Nullable private CipherInputStream cipherInputStream;
+
+ /**
+ * @param upstream The upstream {@link DataSource}.
+ * @param encryptionKey The encryption key.
+ * @param encryptionIv The encryption initialization vector.
+ */
+ public Aes128DataSource(DataSource upstream, byte[] encryptionKey, byte[] encryptionIv) {
+ this.upstream = upstream;
+ this.encryptionKey = encryptionKey;
+ this.encryptionIv = encryptionIv;
+ }
+
+ @Override
+ public final void addTransferListener(TransferListener transferListener) {
+ upstream.addTransferListener(transferListener);
+ }
+
+ @Override
+ public final long open(DataSpec dataSpec) throws IOException {
+ Cipher cipher;
+ try {
+ cipher = getCipherInstance();
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+ throw new RuntimeException(e);
+ }
+
+ Key cipherKey = new SecretKeySpec(encryptionKey, "AES");
+ AlgorithmParameterSpec cipherIV = new IvParameterSpec(encryptionIv);
+
+ try {
+ cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV);
+ } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+ throw new RuntimeException(e);
+ }
+
+ DataSourceInputStream inputStream = new DataSourceInputStream(upstream, dataSpec);
+ cipherInputStream = new CipherInputStream(inputStream, cipher);
+ inputStream.open();
+
+ return C.LENGTH_UNSET;
+ }
+
+ @Override
+ public final int read(byte[] buffer, int offset, int readLength) throws IOException {
+ Assertions.checkNotNull(cipherInputStream);
+ int bytesRead = cipherInputStream.read(buffer, offset, readLength);
+ if (bytesRead < 0) {
+ return C.RESULT_END_OF_INPUT;
+ }
+ return bytesRead;
+ }
+
+ @Override
+ @Nullable
+ public final Uri getUri() {
+ return upstream.getUri();
+ }
+
+ @Override
+ public final Map<String, List<String>> getResponseHeaders() {
+ return upstream.getResponseHeaders();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (cipherInputStream != null) {
+ cipherInputStream = null;
+ upstream.close();
+ }
+ }
+
+ protected Cipher getCipherInstance() throws NoSuchPaddingException, NoSuchAlgorithmException {
+ return Cipher.getInstance("AES/CBC/PKCS7Padding");
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java
new file mode 100644
index 0000000000..cbe2f797b7
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+
+/**
+ * Default implementation of {@link HlsDataSourceFactory}.
+ */
+public final class DefaultHlsDataSourceFactory implements HlsDataSourceFactory {
+
+ private final DataSource.Factory dataSourceFactory;
+
+ /**
+ * @param dataSourceFactory The {@link DataSource.Factory} to use for all data types.
+ */
+ public DefaultHlsDataSourceFactory(DataSource.Factory dataSourceFactory) {
+ this.dataSourceFactory = dataSourceFactory;
+ }
+
+ @Override
+ public DataSource createDataSource(int dataType) {
+ return dataSourceFactory.createDataSource();
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java
new file mode 100644
index 0000000000..6f39e1bff8
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java
@@ -0,0 +1,338 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Mp3Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac3Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac4Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.AdtsExtractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsExtractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster;
+import java.io.EOFException;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Default {@link HlsExtractorFactory} implementation.
+ */
+public final class DefaultHlsExtractorFactory implements HlsExtractorFactory {
+
+ public static final String AAC_FILE_EXTENSION = ".aac";
+ public static final String AC3_FILE_EXTENSION = ".ac3";
+ public static final String EC3_FILE_EXTENSION = ".ec3";
+ public static final String AC4_FILE_EXTENSION = ".ac4";
+ public static final String MP3_FILE_EXTENSION = ".mp3";
+ public static final String MP4_FILE_EXTENSION = ".mp4";
+ public static final String M4_FILE_EXTENSION_PREFIX = ".m4";
+ public static final String MP4_FILE_EXTENSION_PREFIX = ".mp4";
+ public static final String CMF_FILE_EXTENSION_PREFIX = ".cmf";
+ public static final String VTT_FILE_EXTENSION = ".vtt";
+ public static final String WEBVTT_FILE_EXTENSION = ".webvtt";
+
+ @DefaultTsPayloadReaderFactory.Flags private final int payloadReaderFactoryFlags;
+ private final boolean exposeCea608WhenMissingDeclarations;
+
+ /**
+ * Equivalent to {@link #DefaultHlsExtractorFactory(int, boolean) new
+ * DefaultHlsExtractorFactory(payloadReaderFactoryFlags = 0, exposeCea608WhenMissingDeclarations =
+ * true)}
+ */
+ public DefaultHlsExtractorFactory() {
+ this(/* payloadReaderFactoryFlags= */ 0, /* exposeCea608WhenMissingDeclarations */ true);
+ }
+
+ /**
+ * Creates a factory for HLS segment extractors.
+ *
+ * @param payloadReaderFactoryFlags Flags to add when constructing any {@link
+ * DefaultTsPayloadReaderFactory} instances. Other flags may be added on top of {@code
+ * payloadReaderFactoryFlags} when creating {@link DefaultTsPayloadReaderFactory}.
+ * @param exposeCea608WhenMissingDeclarations Whether created {@link TsExtractor} instances should
+ * expose a CEA-608 track should the master playlist contain no Closed Captions declarations.
+ * If the master playlist contains any Closed Captions declarations, this flag is ignored.
+ */
+ public DefaultHlsExtractorFactory(
+ int payloadReaderFactoryFlags, boolean exposeCea608WhenMissingDeclarations) {
+ this.payloadReaderFactoryFlags = payloadReaderFactoryFlags;
+ this.exposeCea608WhenMissingDeclarations = exposeCea608WhenMissingDeclarations;
+ }
+
+ @Override
+ public Result createExtractor(
+ @Nullable Extractor previousExtractor,
+ Uri uri,
+ Format format,
+ @Nullable List<Format> muxedCaptionFormats,
+ TimestampAdjuster timestampAdjuster,
+ Map<String, List<String>> responseHeaders,
+ ExtractorInput extractorInput)
+ throws InterruptedException, IOException {
+
+ if (previousExtractor != null) {
+ // A extractor has already been successfully used. Return one of the same type.
+ if (isReusable(previousExtractor)) {
+ return buildResult(previousExtractor);
+ } else {
+ Result result =
+ buildResultForSameExtractorType(previousExtractor, format, timestampAdjuster);
+ if (result == null) {
+ throw new IllegalArgumentException(
+ "Unexpected previousExtractor type: " + previousExtractor.getClass().getSimpleName());
+ }
+ }
+ }
+
+ // Try selecting the extractor by the file extension.
+ Extractor extractorByFileExtension =
+ createExtractorByFileExtension(uri, format, muxedCaptionFormats, timestampAdjuster);
+ extractorInput.resetPeekPosition();
+ if (sniffQuietly(extractorByFileExtension, extractorInput)) {
+ return buildResult(extractorByFileExtension);
+ }
+
+ // We need to manually sniff each known type, without retrying the one selected by file
+ // extension.
+
+ if (!(extractorByFileExtension instanceof WebvttExtractor)) {
+ WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster);
+ if (sniffQuietly(webvttExtractor, extractorInput)) {
+ return buildResult(webvttExtractor);
+ }
+ }
+
+ if (!(extractorByFileExtension instanceof AdtsExtractor)) {
+ AdtsExtractor adtsExtractor = new AdtsExtractor();
+ if (sniffQuietly(adtsExtractor, extractorInput)) {
+ return buildResult(adtsExtractor);
+ }
+ }
+
+ if (!(extractorByFileExtension instanceof Ac3Extractor)) {
+ Ac3Extractor ac3Extractor = new Ac3Extractor();
+ if (sniffQuietly(ac3Extractor, extractorInput)) {
+ return buildResult(ac3Extractor);
+ }
+ }
+
+ if (!(extractorByFileExtension instanceof Ac4Extractor)) {
+ Ac4Extractor ac4Extractor = new Ac4Extractor();
+ if (sniffQuietly(ac4Extractor, extractorInput)) {
+ return buildResult(ac4Extractor);
+ }
+ }
+
+ if (!(extractorByFileExtension instanceof Mp3Extractor)) {
+ Mp3Extractor mp3Extractor =
+ new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0);
+ if (sniffQuietly(mp3Extractor, extractorInput)) {
+ return buildResult(mp3Extractor);
+ }
+ }
+
+ if (!(extractorByFileExtension instanceof FragmentedMp4Extractor)) {
+ FragmentedMp4Extractor fragmentedMp4Extractor =
+ createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats);
+ if (sniffQuietly(fragmentedMp4Extractor, extractorInput)) {
+ return buildResult(fragmentedMp4Extractor);
+ }
+ }
+
+ if (!(extractorByFileExtension instanceof TsExtractor)) {
+ TsExtractor tsExtractor =
+ createTsExtractor(
+ payloadReaderFactoryFlags,
+ exposeCea608WhenMissingDeclarations,
+ format,
+ muxedCaptionFormats,
+ timestampAdjuster);
+ if (sniffQuietly(tsExtractor, extractorInput)) {
+ return buildResult(tsExtractor);
+ }
+ }
+
+ // Fall back on the extractor created by file extension.
+ return buildResult(extractorByFileExtension);
+ }
+
+ private Extractor createExtractorByFileExtension(
+ Uri uri,
+ Format format,
+ @Nullable List<Format> muxedCaptionFormats,
+ TimestampAdjuster timestampAdjuster) {
+ String lastPathSegment = uri.getLastPathSegment();
+ if (lastPathSegment == null) {
+ lastPathSegment = "";
+ }
+ if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType)
+ || lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION)
+ || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) {
+ return new WebvttExtractor(format.language, timestampAdjuster);
+ } else if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) {
+ return new AdtsExtractor();
+ } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION)
+ || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) {
+ return new Ac3Extractor();
+ } else if (lastPathSegment.endsWith(AC4_FILE_EXTENSION)) {
+ return new Ac4Extractor();
+ } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) {
+ return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0);
+ } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION)
+ || lastPathSegment.startsWith(M4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4)
+ || lastPathSegment.startsWith(MP4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5)
+ || lastPathSegment.startsWith(CMF_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5)) {
+ return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats);
+ } else {
+ // For any other file extension, we assume TS format.
+ return createTsExtractor(
+ payloadReaderFactoryFlags,
+ exposeCea608WhenMissingDeclarations,
+ format,
+ muxedCaptionFormats,
+ timestampAdjuster);
+ }
+ }
+
+ private static TsExtractor createTsExtractor(
+ @DefaultTsPayloadReaderFactory.Flags int userProvidedPayloadReaderFactoryFlags,
+ boolean exposeCea608WhenMissingDeclarations,
+ Format format,
+ @Nullable List<Format> muxedCaptionFormats,
+ TimestampAdjuster timestampAdjuster) {
+ @DefaultTsPayloadReaderFactory.Flags
+ int payloadReaderFactoryFlags =
+ DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM
+ | userProvidedPayloadReaderFactoryFlags;
+ if (muxedCaptionFormats != null) {
+ // The playlist declares closed caption renditions, we should ignore descriptors.
+ payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_OVERRIDE_CAPTION_DESCRIPTORS;
+ } else if (exposeCea608WhenMissingDeclarations) {
+ // The playlist does not provide any closed caption information. We preemptively declare a
+ // closed caption track on channel 0.
+ muxedCaptionFormats =
+ Collections.singletonList(
+ Format.createTextSampleFormat(
+ /* id= */ null,
+ MimeTypes.APPLICATION_CEA608,
+ /* selectionFlags= */ 0,
+ /* language= */ null));
+ } else {
+ muxedCaptionFormats = Collections.emptyList();
+ }
+ String codecs = format.codecs;
+ if (!TextUtils.isEmpty(codecs)) {
+ // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really
+ // exist. If we know from the codec attribute that they don't exist, then we can
+ // explicitly ignore them even if they're declared.
+ if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) {
+ payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM;
+ }
+ if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) {
+ payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM;
+ }
+ }
+
+ return new TsExtractor(
+ TsExtractor.MODE_HLS,
+ timestampAdjuster,
+ new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats));
+ }
+
+ private static FragmentedMp4Extractor createFragmentedMp4Extractor(
+ TimestampAdjuster timestampAdjuster,
+ Format format,
+ @Nullable List<Format> muxedCaptionFormats) {
+ // Only enable the EMSG TrackOutput if this is the 'variant' track (i.e. the main one) to avoid
+ // creating a separate EMSG track for every audio track in a video stream.
+ return new FragmentedMp4Extractor(
+ /* flags= */ isFmp4Variant(format) ? FragmentedMp4Extractor.FLAG_ENABLE_EMSG_TRACK : 0,
+ timestampAdjuster,
+ /* sideloadedTrack= */ null,
+ muxedCaptionFormats != null ? muxedCaptionFormats : Collections.emptyList());
+ }
+
+ /** Returns true if this {@code format} represents a 'variant' track (i.e. the main one). */
+ private static boolean isFmp4Variant(Format format) {
+ Metadata metadata = format.metadata;
+ if (metadata == null) {
+ return false;
+ }
+ for (int i = 0; i < metadata.length(); i++) {
+ Metadata.Entry entry = metadata.get(i);
+ if (entry instanceof HlsTrackMetadataEntry) {
+ return !((HlsTrackMetadataEntry) entry).variantInfos.isEmpty();
+ }
+ }
+ return false;
+ }
+
+ @Nullable
+ private static Result buildResultForSameExtractorType(
+ Extractor previousExtractor, Format format, TimestampAdjuster timestampAdjuster) {
+ if (previousExtractor instanceof WebvttExtractor) {
+ return buildResult(new WebvttExtractor(format.language, timestampAdjuster));
+ } else if (previousExtractor instanceof AdtsExtractor) {
+ return buildResult(new AdtsExtractor());
+ } else if (previousExtractor instanceof Ac3Extractor) {
+ return buildResult(new Ac3Extractor());
+ } else if (previousExtractor instanceof Ac4Extractor) {
+ return buildResult(new Ac4Extractor());
+ } else if (previousExtractor instanceof Mp3Extractor) {
+ return buildResult(new Mp3Extractor());
+ } else {
+ return null;
+ }
+ }
+
+ private static Result buildResult(Extractor extractor) {
+ return new Result(
+ extractor,
+ extractor instanceof AdtsExtractor
+ || extractor instanceof Ac3Extractor
+ || extractor instanceof Ac4Extractor
+ || extractor instanceof Mp3Extractor,
+ isReusable(extractor));
+ }
+
+ private static boolean sniffQuietly(Extractor extractor, ExtractorInput input)
+ throws InterruptedException, IOException {
+ boolean result = false;
+ try {
+ result = extractor.sniff(input);
+ } catch (EOFException e) {
+ // Do nothing.
+ } finally {
+ input.resetPeekPosition();
+ }
+ return result;
+ }
+
+ private static boolean isReusable(Extractor previousExtractor) {
+ return previousExtractor instanceof TsExtractor
+ || previousExtractor instanceof FragmentedMp4Extractor;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java
new file mode 100644
index 0000000000..eab538582d
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * LRU cache that holds up to {@code maxSize} full-segment-encryption keys. Which each addition,
+ * once the cache's size exceeds {@code maxSize}, the oldest item (according to insertion order) is
+ * removed.
+ */
+/* package */ final class FullSegmentEncryptionKeyCache {
+
+ private final LinkedHashMap<Uri, byte[]> backingMap;
+
+ public FullSegmentEncryptionKeyCache(int maxSize) {
+ backingMap =
+ new LinkedHashMap<Uri, byte[]>(
+ /* initialCapacity= */ maxSize + 1, /* loadFactor= */ 1, /* accessOrder= */ false) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<Uri, byte[]> eldest) {
+ return size() > maxSize;
+ }
+ };
+ }
+
+ /**
+ * Returns the {@code encryptionKey} cached against this {@code uri}, or null if {@code uri} is
+ * null or not present in the cache.
+ */
+ @Nullable
+ public byte[] get(@Nullable Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ return backingMap.get(uri);
+ }
+
+ /**
+ * Inserts an entry into the cache.
+ *
+ * @throws NullPointerException if {@code uri} or {@code encryptionKey} are null.
+ */
+ @Nullable
+ public byte[] put(Uri uri, byte[] encryptionKey) {
+ return backingMap.put(Assertions.checkNotNull(uri), Assertions.checkNotNull(encryptionKey));
+ }
+
+ /**
+ * Returns true if {@code uri} is present in the cache.
+ *
+ * @throws NullPointerException if {@code uri} is null.
+ */
+ public boolean containsUri(Uri uri) {
+ return backingMap.containsKey(Assertions.checkNotNull(uri));
+ }
+
+ /**
+ * Removes {@code uri} from the cache. If {@code uri} was present in the cahce, this returns the
+ * corresponding {@code encryptionKey}, otherwise null.
+ *
+ * @throws NullPointerException if {@code uri} is null.
+ */
+ @Nullable
+ public byte[] remove(Uri uri) {
+ return backingMap.remove(Assertions.checkNotNull(uri));
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java
new file mode 100644
index 0000000000..da935389d8
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java
@@ -0,0 +1,668 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import android.os.SystemClock;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.BehindLiveWindowException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.Chunk;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.DataChunk;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.BaseTrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/** Source of Hls (possibly adaptive) chunks. */
+/* package */ class HlsChunkSource {
+
+ /**
+ * Chunk holder that allows the scheduling of retries.
+ */
+ public static final class HlsChunkHolder {
+
+ public HlsChunkHolder() {
+ clear();
+ }
+
+ /** The chunk to be loaded next. */
+ @Nullable public Chunk chunk;
+
+ /**
+ * Indicates that the end of the stream has been reached.
+ */
+ public boolean endOfStream;
+
+ /** Indicates that the chunk source is waiting for the referred playlist to be refreshed. */
+ @Nullable public Uri playlistUrl;
+
+ /**
+ * Clears the holder.
+ */
+ public void clear() {
+ chunk = null;
+ endOfStream = false;
+ playlistUrl = null;
+ }
+
+ }
+
+ /**
+ * The maximum number of keys that the key cache can hold. This value must be 2 or greater in
+ * order to hold initialization segment and media segment keys simultaneously.
+ */
+ private static final int KEY_CACHE_SIZE = 4;
+
+ private final HlsExtractorFactory extractorFactory;
+ private final DataSource mediaDataSource;
+ private final DataSource encryptionDataSource;
+ private final TimestampAdjusterProvider timestampAdjusterProvider;
+ private final Uri[] playlistUrls;
+ private final Format[] playlistFormats;
+ private final HlsPlaylistTracker playlistTracker;
+ private final TrackGroup trackGroup;
+ @Nullable private final List<Format> muxedCaptionFormats;
+ private final FullSegmentEncryptionKeyCache keyCache;
+
+ private boolean isTimestampMaster;
+ private byte[] scratchSpace;
+ @Nullable private IOException fatalError;
+ @Nullable private Uri expectedPlaylistUrl;
+ private boolean independentSegments;
+
+ // Note: The track group in the selection is typically *not* equal to trackGroup. This is due to
+ // the way in which HlsSampleStreamWrapper generates track groups. Use only index based methods
+ // in TrackSelection to avoid unexpected behavior.
+ private TrackSelection trackSelection;
+ private long liveEdgeInPeriodTimeUs;
+ private boolean seenExpectedPlaylistError;
+
+ /**
+ * @param extractorFactory An {@link HlsExtractorFactory} from which to obtain the extractors for
+ * media chunks.
+ * @param playlistTracker The {@link HlsPlaylistTracker} from which to obtain media playlists.
+ * @param playlistUrls The {@link Uri}s of the media playlists that can be adapted between by this
+ * chunk source.
+ * @param playlistFormats The {@link Format Formats} corresponding to the media playlists.
+ * @param dataSourceFactory An {@link HlsDataSourceFactory} to create {@link DataSource}s for the
+ * chunks.
+ * @param mediaTransferListener The transfer listener which should be informed of any media data
+ * transfers. May be null if no listener is available.
+ * @param timestampAdjusterProvider A provider of {@link TimestampAdjuster} instances. If multiple
+ * {@link HlsChunkSource}s are used for a single playback, they should all share the same
+ * provider.
+ * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption
+ * information is available in the master playlist.
+ */
+ public HlsChunkSource(
+ HlsExtractorFactory extractorFactory,
+ HlsPlaylistTracker playlistTracker,
+ Uri[] playlistUrls,
+ Format[] playlistFormats,
+ HlsDataSourceFactory dataSourceFactory,
+ @Nullable TransferListener mediaTransferListener,
+ TimestampAdjusterProvider timestampAdjusterProvider,
+ @Nullable List<Format> muxedCaptionFormats) {
+ this.extractorFactory = extractorFactory;
+ this.playlistTracker = playlistTracker;
+ this.playlistUrls = playlistUrls;
+ this.playlistFormats = playlistFormats;
+ this.timestampAdjusterProvider = timestampAdjusterProvider;
+ this.muxedCaptionFormats = muxedCaptionFormats;
+ keyCache = new FullSegmentEncryptionKeyCache(KEY_CACHE_SIZE);
+ scratchSpace = Util.EMPTY_BYTE_ARRAY;
+ liveEdgeInPeriodTimeUs = C.TIME_UNSET;
+ mediaDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MEDIA);
+ if (mediaTransferListener != null) {
+ mediaDataSource.addTransferListener(mediaTransferListener);
+ }
+ encryptionDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_DRM);
+ trackGroup = new TrackGroup(playlistFormats);
+ int[] initialTrackSelection = new int[playlistUrls.length];
+ for (int i = 0; i < playlistUrls.length; i++) {
+ initialTrackSelection[i] = i;
+ }
+ trackSelection = new InitializationTrackSelection(trackGroup, initialTrackSelection);
+ }
+
+ /**
+ * If the source is currently having difficulty providing chunks, then this method throws the
+ * underlying error. Otherwise does nothing.
+ *
+ * @throws IOException The underlying error.
+ */
+ public void maybeThrowError() throws IOException {
+ if (fatalError != null) {
+ throw fatalError;
+ }
+ if (expectedPlaylistUrl != null && seenExpectedPlaylistError) {
+ playlistTracker.maybeThrowPlaylistRefreshError(expectedPlaylistUrl);
+ }
+ }
+
+ /**
+ * Returns the track group exposed by the source.
+ */
+ public TrackGroup getTrackGroup() {
+ return trackGroup;
+ }
+
+ /**
+ * Sets the current track selection.
+ *
+ * @param trackSelection The {@link TrackSelection}.
+ */
+ public void setTrackSelection(TrackSelection trackSelection) {
+ this.trackSelection = trackSelection;
+ }
+
+ /** Returns the current {@link TrackSelection}. */
+ public TrackSelection getTrackSelection() {
+ return trackSelection;
+ }
+
+ /**
+ * Resets the source.
+ */
+ public void reset() {
+ fatalError = null;
+ }
+
+ /**
+ * Sets whether this chunk source is responsible for initializing timestamp adjusters.
+ *
+ * @param isTimestampMaster True if this chunk source is responsible for initializing timestamp
+ * adjusters.
+ */
+ public void setIsTimestampMaster(boolean isTimestampMaster) {
+ this.isTimestampMaster = isTimestampMaster;
+ }
+
+ /**
+ * Returns the next chunk to load.
+ *
+ * <p>If a chunk is available then {@link HlsChunkHolder#chunk} is set. If the end of the stream
+ * has been reached then {@link HlsChunkHolder#endOfStream} is set. If a chunk is not available
+ * but the end of the stream has not been reached, {@link HlsChunkHolder#playlistUrl} is set to
+ * contain the {@link Uri} that refers to the playlist that needs refreshing.
+ *
+ * @param playbackPositionUs The current playback position relative to the period start in
+ * microseconds. If playback of the period to which this chunk source belongs has not yet
+ * started, the value will be the starting position in the period minus the duration of any
+ * media in previous periods still to be played.
+ * @param loadPositionUs The current load position relative to the period start in microseconds.
+ * @param queue The queue of buffered {@link HlsMediaChunk}s.
+ * @param allowEndOfStream Whether {@link HlsChunkHolder#endOfStream} is allowed to be set for
+ * non-empty media playlists. If {@code false}, the last available chunk is returned instead.
+ * If the media playlist is empty, {@link HlsChunkHolder#endOfStream} is always set.
+ * @param out A holder to populate.
+ */
+ public void getNextChunk(
+ long playbackPositionUs,
+ long loadPositionUs,
+ List<HlsMediaChunk> queue,
+ boolean allowEndOfStream,
+ HlsChunkHolder out) {
+ HlsMediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1);
+ int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat);
+ long bufferedDurationUs = loadPositionUs - playbackPositionUs;
+ long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(playbackPositionUs);
+ if (previous != null && !independentSegments) {
+ // Unless segments are known to be independent, switching tracks requires downloading
+ // overlapping segments. Hence we subtract the previous segment's duration from the buffered
+ // duration.
+ // This may affect the live-streaming adaptive track selection logic, when we compare the
+ // buffered duration to time-to-live-edge to decide whether to switch. Therefore, we subtract
+ // the duration of the last loaded segment from timeToLiveEdgeUs as well.
+ long subtractedDurationUs = previous.getDurationUs();
+ bufferedDurationUs = Math.max(0, bufferedDurationUs - subtractedDurationUs);
+ if (timeToLiveEdgeUs != C.TIME_UNSET) {
+ timeToLiveEdgeUs = Math.max(0, timeToLiveEdgeUs - subtractedDurationUs);
+ }
+ }
+
+ // Select the track.
+ MediaChunkIterator[] mediaChunkIterators = createMediaChunkIterators(previous, loadPositionUs);
+ trackSelection.updateSelectedTrack(
+ playbackPositionUs, bufferedDurationUs, timeToLiveEdgeUs, queue, mediaChunkIterators);
+ int selectedTrackIndex = trackSelection.getSelectedIndexInTrackGroup();
+
+ boolean switchingTrack = oldTrackIndex != selectedTrackIndex;
+ Uri selectedPlaylistUrl = playlistUrls[selectedTrackIndex];
+ if (!playlistTracker.isSnapshotValid(selectedPlaylistUrl)) {
+ out.playlistUrl = selectedPlaylistUrl;
+ seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl);
+ expectedPlaylistUrl = selectedPlaylistUrl;
+ // Retry when playlist is refreshed.
+ return;
+ }
+ HlsMediaPlaylist mediaPlaylist =
+ playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true);
+ // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be non-null.
+ Assertions.checkNotNull(mediaPlaylist);
+ independentSegments = mediaPlaylist.hasIndependentSegments;
+
+ updateLiveEdgeTimeUs(mediaPlaylist);
+
+ // Select the chunk.
+ long startOfPlaylistInPeriodUs =
+ mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs();
+ long chunkMediaSequence =
+ getChunkMediaSequence(
+ previous, switchingTrack, mediaPlaylist, startOfPlaylistInPeriodUs, loadPositionUs);
+ if (chunkMediaSequence < mediaPlaylist.mediaSequence && previous != null && switchingTrack) {
+ // We try getting the next chunk without adapting in case that's the reason for falling
+ // behind the live window.
+ selectedTrackIndex = oldTrackIndex;
+ selectedPlaylistUrl = playlistUrls[selectedTrackIndex];
+ mediaPlaylist =
+ playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true);
+ // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be
+ // non-null.
+ Assertions.checkNotNull(mediaPlaylist);
+ startOfPlaylistInPeriodUs =
+ mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs();
+ chunkMediaSequence = previous.getNextChunkIndex();
+ }
+
+ if (chunkMediaSequence < mediaPlaylist.mediaSequence) {
+ fatalError = new BehindLiveWindowException();
+ return;
+ }
+
+ int segmentIndexInPlaylist = (int) (chunkMediaSequence - mediaPlaylist.mediaSequence);
+ int availableSegmentCount = mediaPlaylist.segments.size();
+ if (segmentIndexInPlaylist >= availableSegmentCount) {
+ if (mediaPlaylist.hasEndTag) {
+ if (allowEndOfStream || availableSegmentCount == 0) {
+ out.endOfStream = true;
+ return;
+ }
+ segmentIndexInPlaylist = availableSegmentCount - 1;
+ } else /* Live */ {
+ out.playlistUrl = selectedPlaylistUrl;
+ seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl);
+ expectedPlaylistUrl = selectedPlaylistUrl;
+ return;
+ }
+ }
+ // We have a valid playlist snapshot, we can discard any playlist errors at this point.
+ seenExpectedPlaylistError = false;
+ expectedPlaylistUrl = null;
+
+ // Handle encryption.
+ HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(segmentIndexInPlaylist);
+
+ // Check if the segment or its initialization segment are fully encrypted.
+ Uri initSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment.initializationSegment);
+ out.chunk = maybeCreateEncryptionChunkFor(initSegmentKeyUri, selectedTrackIndex);
+ if (out.chunk != null) {
+ return;
+ }
+ Uri mediaSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment);
+ out.chunk = maybeCreateEncryptionChunkFor(mediaSegmentKeyUri, selectedTrackIndex);
+ if (out.chunk != null) {
+ return;
+ }
+
+ out.chunk =
+ HlsMediaChunk.createInstance(
+ extractorFactory,
+ mediaDataSource,
+ playlistFormats[selectedTrackIndex],
+ startOfPlaylistInPeriodUs,
+ mediaPlaylist,
+ segmentIndexInPlaylist,
+ selectedPlaylistUrl,
+ muxedCaptionFormats,
+ trackSelection.getSelectionReason(),
+ trackSelection.getSelectionData(),
+ isTimestampMaster,
+ timestampAdjusterProvider,
+ previous,
+ /* mediaSegmentKey= */ keyCache.get(mediaSegmentKeyUri),
+ /* initSegmentKey= */ keyCache.get(initSegmentKeyUri));
+ }
+
+ /**
+ * Called when the {@link HlsSampleStreamWrapper} has finished loading a chunk obtained from this
+ * source.
+ *
+ * @param chunk The chunk whose load has been completed.
+ */
+ public void onChunkLoadCompleted(Chunk chunk) {
+ if (chunk instanceof EncryptionKeyChunk) {
+ EncryptionKeyChunk encryptionKeyChunk = (EncryptionKeyChunk) chunk;
+ scratchSpace = encryptionKeyChunk.getDataHolder();
+ keyCache.put(
+ encryptionKeyChunk.dataSpec.uri, Assertions.checkNotNull(encryptionKeyChunk.getResult()));
+ }
+ }
+
+ /**
+ * Attempts to blacklist the track associated with the given chunk. Blacklisting will fail if the
+ * track is the only non-blacklisted track in the selection.
+ *
+ * @param chunk The chunk whose load caused the blacklisting attempt.
+ * @param blacklistDurationMs The number of milliseconds for which the track selection should be
+ * blacklisted.
+ * @return Whether the blacklisting succeeded.
+ */
+ public boolean maybeBlacklistTrack(Chunk chunk, long blacklistDurationMs) {
+ return trackSelection.blacklist(
+ trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), blacklistDurationMs);
+ }
+
+ /**
+ * Called when a playlist load encounters an error.
+ *
+ * @param playlistUrl The {@link Uri} of the playlist whose load encountered an error.
+ * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or {@link
+ * C#TIME_UNSET} if the playlist should not be blacklisted.
+ * @return True if blacklisting did not encounter errors. False otherwise.
+ */
+ public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) {
+ int trackGroupIndex = C.INDEX_UNSET;
+ for (int i = 0; i < playlistUrls.length; i++) {
+ if (playlistUrls[i].equals(playlistUrl)) {
+ trackGroupIndex = i;
+ break;
+ }
+ }
+ if (trackGroupIndex == C.INDEX_UNSET) {
+ return true;
+ }
+ int trackSelectionIndex = trackSelection.indexOf(trackGroupIndex);
+ if (trackSelectionIndex == C.INDEX_UNSET) {
+ return true;
+ }
+ seenExpectedPlaylistError |= playlistUrl.equals(expectedPlaylistUrl);
+ return blacklistDurationMs == C.TIME_UNSET
+ || trackSelection.blacklist(trackSelectionIndex, blacklistDurationMs);
+ }
+
+ /**
+ * Returns an array of {@link MediaChunkIterator}s for upcoming media chunks.
+ *
+ * @param previous The previous media chunk. May be null.
+ * @param loadPositionUs The position at which the iterators will start.
+ * @return Array of {@link MediaChunkIterator}s for each track.
+ */
+ public MediaChunkIterator[] createMediaChunkIterators(
+ @Nullable HlsMediaChunk previous, long loadPositionUs) {
+ int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat);
+ MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()];
+ for (int i = 0; i < chunkIterators.length; i++) {
+ int trackIndex = trackSelection.getIndexInTrackGroup(i);
+ Uri playlistUrl = playlistUrls[trackIndex];
+ if (!playlistTracker.isSnapshotValid(playlistUrl)) {
+ chunkIterators[i] = MediaChunkIterator.EMPTY;
+ continue;
+ }
+ HlsMediaPlaylist playlist =
+ playlistTracker.getPlaylistSnapshot(playlistUrl, /* isForPlayback= */ false);
+ // Playlist snapshot is valid (checked by if() above) so playlist must be non-null.
+ Assertions.checkNotNull(playlist);
+ long startOfPlaylistInPeriodUs =
+ playlist.startTimeUs - playlistTracker.getInitialStartTimeUs();
+ boolean switchingTrack = trackIndex != oldTrackIndex;
+ long chunkMediaSequence =
+ getChunkMediaSequence(
+ previous, switchingTrack, playlist, startOfPlaylistInPeriodUs, loadPositionUs);
+ if (chunkMediaSequence < playlist.mediaSequence) {
+ chunkIterators[i] = MediaChunkIterator.EMPTY;
+ continue;
+ }
+ int chunkIndex = (int) (chunkMediaSequence - playlist.mediaSequence);
+ chunkIterators[i] =
+ new HlsMediaPlaylistSegmentIterator(playlist, startOfPlaylistInPeriodUs, chunkIndex);
+ }
+ return chunkIterators;
+ }
+
+ // Private methods.
+
+ /**
+ * Returns the media sequence number of the segment to load next in {@code mediaPlaylist}.
+ *
+ * @param previous The last (at least partially) loaded segment.
+ * @param switchingTrack Whether the segment to load is not preceded by a segment in the same
+ * track.
+ * @param mediaPlaylist The media playlist to which the segment to load belongs.
+ * @param startOfPlaylistInPeriodUs The start of {@code mediaPlaylist} relative to the period
+ * start in microseconds.
+ * @param loadPositionUs The current load position relative to the period start in microseconds.
+ * @return The media sequence of the segment to load.
+ */
+ private long getChunkMediaSequence(
+ @Nullable HlsMediaChunk previous,
+ boolean switchingTrack,
+ HlsMediaPlaylist mediaPlaylist,
+ long startOfPlaylistInPeriodUs,
+ long loadPositionUs) {
+ if (previous == null || switchingTrack) {
+ long endOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs + mediaPlaylist.durationUs;
+ long targetPositionInPeriodUs =
+ (previous == null || independentSegments) ? loadPositionUs : previous.startTimeUs;
+ if (!mediaPlaylist.hasEndTag && targetPositionInPeriodUs >= endOfPlaylistInPeriodUs) {
+ // If the playlist is too old to contain the chunk, we need to refresh it.
+ return mediaPlaylist.mediaSequence + mediaPlaylist.segments.size();
+ }
+ long targetPositionInPlaylistUs = targetPositionInPeriodUs - startOfPlaylistInPeriodUs;
+ return Util.binarySearchFloor(
+ mediaPlaylist.segments,
+ /* value= */ targetPositionInPlaylistUs,
+ /* inclusive= */ true,
+ /* stayInBounds= */ !playlistTracker.isLive() || previous == null)
+ + mediaPlaylist.mediaSequence;
+ }
+ // We ignore the case of previous not having loaded completely, in which case we load the next
+ // segment.
+ return previous.getNextChunkIndex();
+ }
+
+ private long resolveTimeToLiveEdgeUs(long playbackPositionUs) {
+ final boolean resolveTimeToLiveEdgePossible = liveEdgeInPeriodTimeUs != C.TIME_UNSET;
+ return resolveTimeToLiveEdgePossible
+ ? liveEdgeInPeriodTimeUs - playbackPositionUs
+ : C.TIME_UNSET;
+ }
+
+ private void updateLiveEdgeTimeUs(HlsMediaPlaylist mediaPlaylist) {
+ liveEdgeInPeriodTimeUs =
+ mediaPlaylist.hasEndTag
+ ? C.TIME_UNSET
+ : (mediaPlaylist.getEndTimeUs() - playlistTracker.getInitialStartTimeUs());
+ }
+
+ @Nullable
+ private Chunk maybeCreateEncryptionChunkFor(@Nullable Uri keyUri, int selectedTrackIndex) {
+ if (keyUri == null) {
+ return null;
+ }
+
+ byte[] encryptionKey = keyCache.remove(keyUri);
+ if (encryptionKey != null) {
+ // The key was present in the key cache. We re-insert it to prevent it from being evicted by
+ // the following key addition. Note that removal of the key is necessary to affect the
+ // eviction order.
+ keyCache.put(keyUri, encryptionKey);
+ return null;
+ }
+ DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNSET, null, DataSpec.FLAG_ALLOW_GZIP);
+ return new EncryptionKeyChunk(
+ encryptionDataSource,
+ dataSpec,
+ playlistFormats[selectedTrackIndex],
+ trackSelection.getSelectionReason(),
+ trackSelection.getSelectionData(),
+ scratchSpace);
+ }
+
+ @Nullable
+ private static Uri getFullEncryptionKeyUri(HlsMediaPlaylist playlist, @Nullable Segment segment) {
+ if (segment == null || segment.fullSegmentEncryptionKeyUri == null) {
+ return null;
+ }
+ return UriUtil.resolveToUri(playlist.baseUri, segment.fullSegmentEncryptionKeyUri);
+ }
+
+ // Private classes.
+
+ /**
+ * A {@link TrackSelection} to use for initialization.
+ */
+ private static final class InitializationTrackSelection extends BaseTrackSelection {
+
+ private int selectedIndex;
+
+ public InitializationTrackSelection(TrackGroup group, int[] tracks) {
+ super(group, tracks);
+ selectedIndex = indexOf(group.getFormat(0));
+ }
+
+ @Override
+ public void updateSelectedTrack(
+ long playbackPositionUs,
+ long bufferedDurationUs,
+ long availableDurationUs,
+ List<? extends MediaChunk> queue,
+ MediaChunkIterator[] mediaChunkIterators) {
+ long nowMs = SystemClock.elapsedRealtime();
+ if (!isBlacklisted(selectedIndex, nowMs)) {
+ return;
+ }
+ // Try from lowest bitrate to highest.
+ for (int i = length - 1; i >= 0; i--) {
+ if (!isBlacklisted(i, nowMs)) {
+ selectedIndex = i;
+ return;
+ }
+ }
+ // Should never happen.
+ throw new IllegalStateException();
+ }
+
+ @Override
+ public int getSelectedIndex() {
+ return selectedIndex;
+ }
+
+ @Override
+ public int getSelectionReason() {
+ return C.SELECTION_REASON_UNKNOWN;
+ }
+
+ @Override
+ @Nullable
+ public Object getSelectionData() {
+ return null;
+ }
+
+ }
+
+ private static final class EncryptionKeyChunk extends DataChunk {
+
+ private byte @MonotonicNonNull [] result;
+
+ public EncryptionKeyChunk(
+ DataSource dataSource,
+ DataSpec dataSpec,
+ Format trackFormat,
+ int trackSelectionReason,
+ @Nullable Object trackSelectionData,
+ byte[] scratchSpace) {
+ super(dataSource, dataSpec, C.DATA_TYPE_DRM, trackFormat, trackSelectionReason,
+ trackSelectionData, scratchSpace);
+ }
+
+ @Override
+ protected void consume(byte[] data, int limit) {
+ result = Arrays.copyOf(data, limit);
+ }
+
+ /** Return the result of this chunk, or null if loading is not complete. */
+ @Nullable
+ public byte[] getResult() {
+ return result;
+ }
+
+ }
+
+ /** {@link MediaChunkIterator} wrapping a {@link HlsMediaPlaylist}. */
+ private static final class HlsMediaPlaylistSegmentIterator extends BaseMediaChunkIterator {
+
+ private final HlsMediaPlaylist playlist;
+ private final long startOfPlaylistInPeriodUs;
+
+ /**
+ * Creates iterator.
+ *
+ * @param playlist The {@link HlsMediaPlaylist} to wrap.
+ * @param startOfPlaylistInPeriodUs The start time of the playlist in the period, in
+ * microseconds.
+ * @param chunkIndex The index of the first available chunk in the playlist.
+ */
+ public HlsMediaPlaylistSegmentIterator(
+ HlsMediaPlaylist playlist, long startOfPlaylistInPeriodUs, int chunkIndex) {
+ super(/* fromIndex= */ chunkIndex, /* toIndex= */ playlist.segments.size() - 1);
+ this.playlist = playlist;
+ this.startOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs;
+ }
+
+ @Override
+ public DataSpec getDataSpec() {
+ checkInBounds();
+ Segment segment = playlist.segments.get((int) getCurrentIndex());
+ Uri chunkUri = UriUtil.resolveToUri(playlist.baseUri, segment.url);
+ return new DataSpec(
+ chunkUri, segment.byterangeOffset, segment.byterangeLength, /* key= */ null);
+ }
+
+ @Override
+ public long getChunkStartTimeUs() {
+ checkInBounds();
+ Segment segment = playlist.segments.get((int) getCurrentIndex());
+ return startOfPlaylistInPeriodUs + segment.relativeStartTimeUs;
+ }
+
+ @Override
+ public long getChunkEndTimeUs() {
+ checkInBounds();
+ Segment segment = playlist.segments.get((int) getCurrentIndex());
+ long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + segment.relativeStartTimeUs;
+ return segmentStartTimeInPeriodUs + segment.durationUs;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java
new file mode 100644
index 0000000000..66fac54b8d
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+
+/**
+ * Creates {@link DataSource}s for HLS playlists, encryption and media chunks.
+ */
+public interface HlsDataSourceFactory {
+
+ /**
+ * Creates a {@link DataSource} for the given data type.
+ *
+ * @param dataType The data type for which the {@link DataSource} will be used. One of {@link C}
+ * {@code .DATA_TYPE_*} constants.
+ * @return A {@link DataSource} for the given data type.
+ */
+ DataSource createDataSource(int dataType);
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java
new file mode 100644
index 0000000000..8f445f97ed
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Factory for HLS media chunk extractors.
+ */
+public interface HlsExtractorFactory {
+
+ /** Holds an {@link Extractor} and associated parameters. */
+ final class Result {
+
+ /** The created extractor; */
+ public final Extractor extractor;
+ /** Whether the segments for which {@link #extractor} is created are packed audio segments. */
+ public final boolean isPackedAudioExtractor;
+ /**
+ * Whether {@link #extractor} may be reused for following continuous (no immediately preceding
+ * discontinuities) segments of the same variant.
+ */
+ public final boolean isReusable;
+
+ /**
+ * Creates a result.
+ *
+ * @param extractor See {@link #extractor}.
+ * @param isPackedAudioExtractor See {@link #isPackedAudioExtractor}.
+ * @param isReusable See {@link #isReusable}.
+ */
+ public Result(Extractor extractor, boolean isPackedAudioExtractor, boolean isReusable) {
+ this.extractor = extractor;
+ this.isPackedAudioExtractor = isPackedAudioExtractor;
+ this.isReusable = isReusable;
+ }
+ }
+
+ HlsExtractorFactory DEFAULT = new DefaultHlsExtractorFactory();
+
+ /**
+ * Creates an {@link Extractor} for extracting HLS media chunks.
+ *
+ * @param previousExtractor A previously used {@link Extractor} which can be reused if the current
+ * chunk is a continuation of the previously extracted chunk, or null otherwise. It is the
+ * responsibility of implementers to only reuse extractors that are suited for reusage.
+ * @param uri The URI of the media chunk.
+ * @param format A {@link Format} associated with the chunk to extract.
+ * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption
+ * information is available in the master playlist.
+ * @param timestampAdjuster Adjuster corresponding to the provided discontinuity sequence number.
+ * @param responseHeaders The HTTP response headers associated with the media segment or
+ * initialization section to extract.
+ * @param sniffingExtractorInput The first extractor input that will be passed to the returned
+ * extractor's {@link Extractor#read(ExtractorInput, PositionHolder)}. Must only be used to
+ * call {@link Extractor#sniff(ExtractorInput)}.
+ * @return A {@link Result}.
+ * @throws InterruptedException If the thread is interrupted while sniffing.
+ * @throws IOException If an I/O error is encountered while sniffing.
+ */
+ Result createExtractor(
+ @Nullable Extractor previousExtractor,
+ Uri uri,
+ Format format,
+ @Nullable List<Format> muxedCaptionFormats,
+ TimestampAdjuster timestampAdjuster,
+ Map<String, List<String>> responseHeaders,
+ ExtractorInput sniffingExtractorInput)
+ throws InterruptedException, IOException;
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java
new file mode 100644
index 0000000000..52a5632134
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
+
+/**
+ * Holds a master playlist along with a snapshot of one of its media playlists.
+ */
+public final class HlsManifest {
+
+ /**
+ * The master playlist of an HLS stream.
+ */
+ public final HlsMasterPlaylist masterPlaylist;
+ /**
+ * A snapshot of a media playlist referred to by {@link #masterPlaylist}.
+ */
+ public final HlsMediaPlaylist mediaPlaylist;
+
+ /**
+ * @param masterPlaylist The master playlist.
+ * @param mediaPlaylist The media playlist.
+ */
+ HlsManifest(HlsMasterPlaylist masterPlaylist, HlsMediaPlaylist mediaPlaylist) {
+ this.masterPlaylist = masterPlaylist;
+ this.mediaPlaylist = mediaPlaylist;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java
new file mode 100644
index 0000000000..173e53faad
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java
@@ -0,0 +1,519 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.PrivFrame;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.EOFException;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * An HLS {@link MediaChunk}.
+ */
+/* package */ final class HlsMediaChunk extends MediaChunk {
+
+ /**
+ * Creates a new instance.
+ *
+ * @param extractorFactory A {@link HlsExtractorFactory} from which the HLS media chunk extractor
+ * is obtained.
+ * @param dataSource The source from which the data should be loaded.
+ * @param format The chunk format.
+ * @param startOfPlaylistInPeriodUs The position of the playlist in the period in microseconds.
+ * @param mediaPlaylist The media playlist from which this chunk was obtained.
+ * @param playlistUrl The url of the playlist from which this chunk was obtained.
+ * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption
+ * information is available in the master playlist.
+ * @param trackSelectionReason See {@link #trackSelectionReason}.
+ * @param trackSelectionData See {@link #trackSelectionData}.
+ * @param isMasterTimestampSource True if the chunk can initialize the timestamp adjuster.
+ * @param timestampAdjusterProvider The provider from which to obtain the {@link
+ * TimestampAdjuster}.
+ * @param previousChunk The {@link HlsMediaChunk} that preceded this one. May be null.
+ * @param mediaSegmentKey The media segment decryption key, if fully encrypted. Null otherwise.
+ * @param initSegmentKey The initialization segment decryption key, if fully encrypted. Null
+ * otherwise.
+ */
+ public static HlsMediaChunk createInstance(
+ HlsExtractorFactory extractorFactory,
+ DataSource dataSource,
+ Format format,
+ long startOfPlaylistInPeriodUs,
+ HlsMediaPlaylist mediaPlaylist,
+ int segmentIndexInPlaylist,
+ Uri playlistUrl,
+ @Nullable List<Format> muxedCaptionFormats,
+ int trackSelectionReason,
+ @Nullable Object trackSelectionData,
+ boolean isMasterTimestampSource,
+ TimestampAdjusterProvider timestampAdjusterProvider,
+ @Nullable HlsMediaChunk previousChunk,
+ @Nullable byte[] mediaSegmentKey,
+ @Nullable byte[] initSegmentKey) {
+ // Media segment.
+ HlsMediaPlaylist.Segment mediaSegment = mediaPlaylist.segments.get(segmentIndexInPlaylist);
+ DataSpec dataSpec =
+ new DataSpec(
+ UriUtil.resolveToUri(mediaPlaylist.baseUri, mediaSegment.url),
+ mediaSegment.byterangeOffset,
+ mediaSegment.byterangeLength,
+ /* key= */ null);
+ boolean mediaSegmentEncrypted = mediaSegmentKey != null;
+ byte[] mediaSegmentIv =
+ mediaSegmentEncrypted
+ ? getEncryptionIvArray(Assertions.checkNotNull(mediaSegment.encryptionIV))
+ : null;
+ DataSource mediaDataSource = buildDataSource(dataSource, mediaSegmentKey, mediaSegmentIv);
+
+ // Init segment.
+ HlsMediaPlaylist.Segment initSegment = mediaSegment.initializationSegment;
+ DataSpec initDataSpec = null;
+ boolean initSegmentEncrypted = false;
+ DataSource initDataSource = null;
+ if (initSegment != null) {
+ initSegmentEncrypted = initSegmentKey != null;
+ byte[] initSegmentIv =
+ initSegmentEncrypted
+ ? getEncryptionIvArray(Assertions.checkNotNull(initSegment.encryptionIV))
+ : null;
+ Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url);
+ initDataSpec =
+ new DataSpec(
+ initSegmentUri,
+ initSegment.byterangeOffset,
+ initSegment.byterangeLength,
+ /* key= */ null);
+ initDataSource = buildDataSource(dataSource, initSegmentKey, initSegmentIv);
+ }
+
+ long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + mediaSegment.relativeStartTimeUs;
+ long segmentEndTimeInPeriodUs = segmentStartTimeInPeriodUs + mediaSegment.durationUs;
+ int discontinuitySequenceNumber =
+ mediaPlaylist.discontinuitySequence + mediaSegment.relativeDiscontinuitySequence;
+
+ Extractor previousExtractor = null;
+ Id3Decoder id3Decoder;
+ ParsableByteArray scratchId3Data;
+ boolean shouldSpliceIn;
+ if (previousChunk != null) {
+ id3Decoder = previousChunk.id3Decoder;
+ scratchId3Data = previousChunk.scratchId3Data;
+ shouldSpliceIn =
+ !playlistUrl.equals(previousChunk.playlistUrl) || !previousChunk.loadCompleted;
+ previousExtractor =
+ previousChunk.isExtractorReusable
+ && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber
+ && !shouldSpliceIn
+ ? previousChunk.extractor
+ : null;
+ } else {
+ id3Decoder = new Id3Decoder();
+ scratchId3Data = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH);
+ shouldSpliceIn = false;
+ }
+
+ return new HlsMediaChunk(
+ extractorFactory,
+ mediaDataSource,
+ dataSpec,
+ format,
+ mediaSegmentEncrypted,
+ initDataSource,
+ initDataSpec,
+ initSegmentEncrypted,
+ playlistUrl,
+ muxedCaptionFormats,
+ trackSelectionReason,
+ trackSelectionData,
+ segmentStartTimeInPeriodUs,
+ segmentEndTimeInPeriodUs,
+ /* chunkMediaSequence= */ mediaPlaylist.mediaSequence + segmentIndexInPlaylist,
+ discontinuitySequenceNumber,
+ mediaSegment.hasGapTag,
+ isMasterTimestampSource,
+ /* timestampAdjuster= */ timestampAdjusterProvider.getAdjuster(discontinuitySequenceNumber),
+ mediaSegment.drmInitData,
+ previousExtractor,
+ id3Decoder,
+ scratchId3Data,
+ shouldSpliceIn);
+ }
+
+ public static final String PRIV_TIMESTAMP_FRAME_OWNER =
+ "com.apple.streaming.transportStreamTimestamp";
+ private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder();
+
+ private static final AtomicInteger uidSource = new AtomicInteger();
+
+ /**
+ * A unique identifier for the chunk.
+ */
+ public final int uid;
+
+ /**
+ * The discontinuity sequence number of the chunk.
+ */
+ public final int discontinuitySequenceNumber;
+
+ /** The url of the playlist from which this chunk was obtained. */
+ public final Uri playlistUrl;
+
+ @Nullable private final DataSource initDataSource;
+ @Nullable private final DataSpec initDataSpec;
+ @Nullable private final Extractor previousExtractor;
+
+ private final boolean isMasterTimestampSource;
+ private final boolean hasGapTag;
+ private final TimestampAdjuster timestampAdjuster;
+ private final boolean shouldSpliceIn;
+ private final HlsExtractorFactory extractorFactory;
+ @Nullable private final List<Format> muxedCaptionFormats;
+ @Nullable private final DrmInitData drmInitData;
+ private final Id3Decoder id3Decoder;
+ private final ParsableByteArray scratchId3Data;
+ private final boolean mediaSegmentEncrypted;
+ private final boolean initSegmentEncrypted;
+
+ @MonotonicNonNull private Extractor extractor;
+ private boolean isExtractorReusable;
+ @MonotonicNonNull private HlsSampleStreamWrapper output;
+ // nextLoadPosition refers to the init segment if initDataLoadRequired is true.
+ // Otherwise, nextLoadPosition refers to the media segment.
+ private int nextLoadPosition;
+ private boolean initDataLoadRequired;
+ private volatile boolean loadCanceled;
+ private boolean loadCompleted;
+
+ private HlsMediaChunk(
+ HlsExtractorFactory extractorFactory,
+ DataSource mediaDataSource,
+ DataSpec dataSpec,
+ Format format,
+ boolean mediaSegmentEncrypted,
+ @Nullable DataSource initDataSource,
+ @Nullable DataSpec initDataSpec,
+ boolean initSegmentEncrypted,
+ Uri playlistUrl,
+ @Nullable List<Format> muxedCaptionFormats,
+ int trackSelectionReason,
+ @Nullable Object trackSelectionData,
+ long startTimeUs,
+ long endTimeUs,
+ long chunkMediaSequence,
+ int discontinuitySequenceNumber,
+ boolean hasGapTag,
+ boolean isMasterTimestampSource,
+ TimestampAdjuster timestampAdjuster,
+ @Nullable DrmInitData drmInitData,
+ @Nullable Extractor previousExtractor,
+ Id3Decoder id3Decoder,
+ ParsableByteArray scratchId3Data,
+ boolean shouldSpliceIn) {
+ super(
+ mediaDataSource,
+ dataSpec,
+ format,
+ trackSelectionReason,
+ trackSelectionData,
+ startTimeUs,
+ endTimeUs,
+ chunkMediaSequence);
+ this.mediaSegmentEncrypted = mediaSegmentEncrypted;
+ this.discontinuitySequenceNumber = discontinuitySequenceNumber;
+ this.initDataSpec = initDataSpec;
+ this.initDataSource = initDataSource;
+ this.initDataLoadRequired = initDataSpec != null;
+ this.initSegmentEncrypted = initSegmentEncrypted;
+ this.playlistUrl = playlistUrl;
+ this.isMasterTimestampSource = isMasterTimestampSource;
+ this.timestampAdjuster = timestampAdjuster;
+ this.hasGapTag = hasGapTag;
+ this.extractorFactory = extractorFactory;
+ this.muxedCaptionFormats = muxedCaptionFormats;
+ this.drmInitData = drmInitData;
+ this.previousExtractor = previousExtractor;
+ this.id3Decoder = id3Decoder;
+ this.scratchId3Data = scratchId3Data;
+ this.shouldSpliceIn = shouldSpliceIn;
+ uid = uidSource.getAndIncrement();
+ }
+
+ /**
+ * Initializes the chunk for loading, setting the {@link HlsSampleStreamWrapper} that will receive
+ * samples as they are loaded.
+ *
+ * @param output The output that will receive the loaded samples.
+ */
+ public void init(HlsSampleStreamWrapper output) {
+ this.output = output;
+ output.init(uid, shouldSpliceIn);
+ }
+
+ @Override
+ public boolean isLoadCompleted() {
+ return loadCompleted;
+ }
+
+ // Loadable implementation
+
+ @Override
+ public void cancelLoad() {
+ loadCanceled = true;
+ }
+
+ @Override
+ public void load() throws IOException, InterruptedException {
+ // output == null means init() hasn't been called.
+ Assertions.checkNotNull(output);
+ if (extractor == null && previousExtractor != null) {
+ extractor = previousExtractor;
+ isExtractorReusable = true;
+ initDataLoadRequired = false;
+ }
+ maybeLoadInitData();
+ if (!loadCanceled) {
+ if (!hasGapTag) {
+ loadMedia();
+ }
+ loadCompleted = true;
+ }
+ }
+
+ // Internal methods.
+
+ @RequiresNonNull("output")
+ private void maybeLoadInitData() throws IOException, InterruptedException {
+ if (!initDataLoadRequired) {
+ return;
+ }
+ // initDataLoadRequired => initDataSource != null && initDataSpec != null
+ Assertions.checkNotNull(initDataSource);
+ Assertions.checkNotNull(initDataSpec);
+ feedDataToExtractor(initDataSource, initDataSpec, initSegmentEncrypted);
+ nextLoadPosition = 0;
+ initDataLoadRequired = false;
+ }
+
+ @RequiresNonNull("output")
+ private void loadMedia() throws IOException, InterruptedException {
+ if (!isMasterTimestampSource) {
+ timestampAdjuster.waitUntilInitialized();
+ } else if (timestampAdjuster.getFirstSampleTimestampUs() == TimestampAdjuster.DO_NOT_OFFSET) {
+ // We're the master and we haven't set the desired first sample timestamp yet.
+ timestampAdjuster.setFirstSampleTimestampUs(startTimeUs);
+ }
+ feedDataToExtractor(dataSource, dataSpec, mediaSegmentEncrypted);
+ }
+
+ /**
+ * Attempts to feed the given {@code dataSpec} to {@code this.extractor}. Whenever the operation
+ * concludes (because of a thrown exception or because the operation finishes), the number of fed
+ * bytes is written to {@code nextLoadPosition}.
+ */
+ @RequiresNonNull("output")
+ private void feedDataToExtractor(
+ DataSource dataSource, DataSpec dataSpec, boolean dataIsEncrypted)
+ throws IOException, InterruptedException {
+ // If we previously fed part of this chunk to the extractor, we need to skip it this time. For
+ // encrypted content we need to skip the data by reading it through the source, so as to ensure
+ // correct decryption of the remainder of the chunk. For clear content, we can request the
+ // remainder of the chunk directly.
+ DataSpec loadDataSpec;
+ boolean skipLoadedBytes;
+ if (dataIsEncrypted) {
+ loadDataSpec = dataSpec;
+ skipLoadedBytes = nextLoadPosition != 0;
+ } else {
+ loadDataSpec = dataSpec.subrange(nextLoadPosition);
+ skipLoadedBytes = false;
+ }
+ try {
+ ExtractorInput input = prepareExtraction(dataSource, loadDataSpec);
+ if (skipLoadedBytes) {
+ input.skipFully(nextLoadPosition);
+ }
+ try {
+ int result = Extractor.RESULT_CONTINUE;
+ while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
+ result = extractor.read(input, DUMMY_POSITION_HOLDER);
+ }
+ } finally {
+ nextLoadPosition = (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
+ }
+ } finally {
+ Util.closeQuietly(dataSource);
+ }
+ }
+
+ @RequiresNonNull("output")
+ @EnsuresNonNull("extractor")
+ private DefaultExtractorInput prepareExtraction(DataSource dataSource, DataSpec dataSpec)
+ throws IOException, InterruptedException {
+ long bytesToRead = dataSource.open(dataSpec);
+ DefaultExtractorInput extractorInput =
+ new DefaultExtractorInput(dataSource, dataSpec.absoluteStreamPosition, bytesToRead);
+
+ if (extractor == null) {
+ long id3Timestamp = peekId3PrivTimestamp(extractorInput);
+ extractorInput.resetPeekPosition();
+
+ HlsExtractorFactory.Result result =
+ extractorFactory.createExtractor(
+ previousExtractor,
+ dataSpec.uri,
+ trackFormat,
+ muxedCaptionFormats,
+ timestampAdjuster,
+ dataSource.getResponseHeaders(),
+ extractorInput);
+ extractor = result.extractor;
+ isExtractorReusable = result.isReusable;
+ if (result.isPackedAudioExtractor) {
+ output.setSampleOffsetUs(
+ id3Timestamp != C.TIME_UNSET
+ ? timestampAdjuster.adjustTsTimestamp(id3Timestamp)
+ : startTimeUs);
+ } else {
+ // In case the container format changes mid-stream to non-packed-audio, we need to reset
+ // the timestamp offset.
+ output.setSampleOffsetUs(/* sampleOffsetUs= */ 0L);
+ }
+ output.onNewExtractor();
+ extractor.init(output);
+ }
+ output.setDrmInitData(drmInitData);
+ return extractorInput;
+ }
+
+ /**
+ * Peek the presentation timestamp of the first sample in the chunk from an ID3 PRIV as defined
+ * in the HLS spec, version 20, Section 3.4. Returns {@link C#TIME_UNSET} if the frame is not
+ * found. This method only modifies the peek position.
+ *
+ * @param input The {@link ExtractorInput} to obtain the PRIV frame from.
+ * @return The parsed, adjusted timestamp in microseconds
+ * @throws IOException If an error occurred peeking from the input.
+ * @throws InterruptedException If the thread was interrupted.
+ */
+ private long peekId3PrivTimestamp(ExtractorInput input) throws IOException, InterruptedException {
+ input.resetPeekPosition();
+ try {
+ input.peekFully(scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH);
+ } catch (EOFException e) {
+ // The input isn't long enough for there to be any ID3 data.
+ return C.TIME_UNSET;
+ }
+ scratchId3Data.reset(Id3Decoder.ID3_HEADER_LENGTH);
+ int id = scratchId3Data.readUnsignedInt24();
+ if (id != Id3Decoder.ID3_TAG) {
+ return C.TIME_UNSET;
+ }
+ scratchId3Data.skipBytes(3); // version(2), flags(1).
+ int id3Size = scratchId3Data.readSynchSafeInt();
+ int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH;
+ if (requiredCapacity > scratchId3Data.capacity()) {
+ byte[] data = scratchId3Data.data;
+ scratchId3Data.reset(requiredCapacity);
+ System.arraycopy(data, 0, scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH);
+ }
+ input.peekFully(scratchId3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size);
+ Metadata metadata = id3Decoder.decode(scratchId3Data.data, id3Size);
+ if (metadata == null) {
+ return C.TIME_UNSET;
+ }
+ int metadataLength = metadata.length();
+ for (int i = 0; i < metadataLength; i++) {
+ Metadata.Entry frame = metadata.get(i);
+ if (frame instanceof PrivFrame) {
+ PrivFrame privFrame = (PrivFrame) frame;
+ if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) {
+ System.arraycopy(
+ privFrame.privateData, 0, scratchId3Data.data, 0, 8 /* timestamp size */);
+ scratchId3Data.reset(8);
+ // The top 31 bits should be zeros, but explicitly zero them to wrap in the case that the
+ // streaming provider forgot. See: https://github.com/google/ExoPlayer/pull/3495.
+ return scratchId3Data.readLong() & 0x1FFFFFFFFL;
+ }
+ }
+ }
+ return C.TIME_UNSET;
+ }
+
+ // Internal methods.
+
+ private static byte[] getEncryptionIvArray(String ivString) {
+ String trimmedIv;
+ if (Util.toLowerInvariant(ivString).startsWith("0x")) {
+ trimmedIv = ivString.substring(2);
+ } else {
+ trimmedIv = ivString;
+ }
+
+ byte[] ivData = new BigInteger(trimmedIv, /* radix= */ 16).toByteArray();
+ byte[] ivDataWithPadding = new byte[16];
+ int offset = ivData.length > 16 ? ivData.length - 16 : 0;
+ System.arraycopy(
+ ivData,
+ offset,
+ ivDataWithPadding,
+ ivDataWithPadding.length - ivData.length + offset,
+ ivData.length - offset);
+ return ivDataWithPadding;
+ }
+
+ /**
+ * If the segment is fully encrypted, returns an {@link Aes128DataSource} that wraps the original
+ * in order to decrypt the loaded data. Else returns the original.
+ *
+ * <p>{@code fullSegmentEncryptionKey} & {@code encryptionIv} can either both be null, or neither.
+ */
+ private static DataSource buildDataSource(
+ DataSource dataSource,
+ @Nullable byte[] fullSegmentEncryptionKey,
+ @Nullable byte[] encryptionIv) {
+ if (fullSegmentEncryptionKey != null) {
+ Assertions.checkNotNull(encryptionIv);
+ return new Aes128DataSource(dataSource, fullSegmentEncryptionKey, encryptionIv);
+ }
+ return dataSource;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java
new file mode 100644
index 0000000000..60aa5298c3
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java
@@ -0,0 +1,858 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * A {@link MediaPeriod} that loads an HLS stream.
+ */
+public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper.Callback,
+ HlsPlaylistTracker.PlaylistEventListener {
+
+ private final HlsExtractorFactory extractorFactory;
+ private final HlsPlaylistTracker playlistTracker;
+ private final HlsDataSourceFactory dataSourceFactory;
+ @Nullable private final TransferListener mediaTransferListener;
+ private final DrmSessionManager<?> drmSessionManager;
+ private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
+ private final EventDispatcher eventDispatcher;
+ private final Allocator allocator;
+ private final IdentityHashMap<SampleStream, Integer> streamWrapperIndices;
+ private final TimestampAdjusterProvider timestampAdjusterProvider;
+ private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
+ private final boolean allowChunklessPreparation;
+ private final @HlsMediaSource.MetadataType int metadataType;
+ private final boolean useSessionKeys;
+
+ @Nullable private Callback callback;
+ private int pendingPrepareCount;
+ private @MonotonicNonNull TrackGroupArray trackGroups;
+ private HlsSampleStreamWrapper[] sampleStreamWrappers;
+ private HlsSampleStreamWrapper[] enabledSampleStreamWrappers;
+ // Maps sample stream wrappers to variant/rendition index by matching array positions.
+ private int[][] manifestUrlIndicesPerWrapper;
+ private SequenceableLoader compositeSequenceableLoader;
+ private boolean notifiedReadingStarted;
+
+ /**
+ * Creates an HLS media period.
+ *
+ * @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the segments.
+ * @param playlistTracker A tracker for HLS playlists.
+ * @param dataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for segments
+ * and keys.
+ * @param mediaTransferListener The transfer listener to inform of any media data transfers. May
+ * be null if no listener is available.
+ * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession
+ * DrmSessions} with.
+ * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}.
+ * @param eventDispatcher A dispatcher to notify of events.
+ * @param allocator An {@link Allocator} from which to obtain media buffer allocations.
+ * @param compositeSequenceableLoaderFactory A factory to create composite {@link
+ * SequenceableLoader}s for when this media source loads data from multiple streams.
+ * @param allowChunklessPreparation Whether chunkless preparation is allowed.
+ * @param useSessionKeys Whether to use #EXT-X-SESSION-KEY tags.
+ */
+ public HlsMediaPeriod(
+ HlsExtractorFactory extractorFactory,
+ HlsPlaylistTracker playlistTracker,
+ HlsDataSourceFactory dataSourceFactory,
+ @Nullable TransferListener mediaTransferListener,
+ DrmSessionManager<?> drmSessionManager,
+ LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+ EventDispatcher eventDispatcher,
+ Allocator allocator,
+ CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
+ boolean allowChunklessPreparation,
+ @HlsMediaSource.MetadataType int metadataType,
+ boolean useSessionKeys) {
+ this.extractorFactory = extractorFactory;
+ this.playlistTracker = playlistTracker;
+ this.dataSourceFactory = dataSourceFactory;
+ this.mediaTransferListener = mediaTransferListener;
+ this.drmSessionManager = drmSessionManager;
+ this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
+ this.eventDispatcher = eventDispatcher;
+ this.allocator = allocator;
+ this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
+ this.allowChunklessPreparation = allowChunklessPreparation;
+ this.metadataType = metadataType;
+ this.useSessionKeys = useSessionKeys;
+ compositeSequenceableLoader =
+ compositeSequenceableLoaderFactory.createCompositeSequenceableLoader();
+ streamWrapperIndices = new IdentityHashMap<>();
+ timestampAdjusterProvider = new TimestampAdjusterProvider();
+ sampleStreamWrappers = new HlsSampleStreamWrapper[0];
+ enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0];
+ manifestUrlIndicesPerWrapper = new int[0][];
+ eventDispatcher.mediaPeriodCreated();
+ }
+
+ public void release() {
+ playlistTracker.removeListener(this);
+ for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) {
+ sampleStreamWrapper.release();
+ }
+ callback = null;
+ eventDispatcher.mediaPeriodReleased();
+ }
+
+ @Override
+ public void prepare(Callback callback, long positionUs) {
+ this.callback = callback;
+ playlistTracker.addListener(this);
+ buildAndPrepareSampleStreamWrappers(positionUs);
+ }
+
+ @Override
+ public void maybeThrowPrepareError() throws IOException {
+ for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) {
+ sampleStreamWrapper.maybeThrowPrepareError();
+ }
+ }
+
+ @Override
+ public TrackGroupArray getTrackGroups() {
+ // trackGroups will only be null if period hasn't been prepared or has been released.
+ return Assertions.checkNotNull(trackGroups);
+ }
+
+ // TODO: When the master playlist does not de-duplicate variants by URL and allows Renditions with
+ // null URLs, this method must be updated to calculate stream keys that are compatible with those
+ // that may already be persisted for offline.
+ @Override
+ public List<StreamKey> getStreamKeys(List<TrackSelection> trackSelections) {
+ // See HlsMasterPlaylist.copy for interpretation of StreamKeys.
+ HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist());
+ boolean hasVariants = !masterPlaylist.variants.isEmpty();
+ int audioWrapperOffset = hasVariants ? 1 : 0;
+ // Subtitle sample stream wrappers are held last.
+ int subtitleWrapperOffset = sampleStreamWrappers.length - masterPlaylist.subtitles.size();
+
+ TrackGroupArray mainWrapperTrackGroups;
+ int mainWrapperPrimaryGroupIndex;
+ int[] mainWrapperVariantIndices;
+ if (hasVariants) {
+ HlsSampleStreamWrapper mainWrapper = sampleStreamWrappers[0];
+ mainWrapperVariantIndices = manifestUrlIndicesPerWrapper[0];
+ mainWrapperTrackGroups = mainWrapper.getTrackGroups();
+ mainWrapperPrimaryGroupIndex = mainWrapper.getPrimaryTrackGroupIndex();
+ } else {
+ mainWrapperVariantIndices = new int[0];
+ mainWrapperTrackGroups = TrackGroupArray.EMPTY;
+ mainWrapperPrimaryGroupIndex = 0;
+ }
+
+ List<StreamKey> streamKeys = new ArrayList<>();
+ boolean needsPrimaryTrackGroupSelection = false;
+ boolean hasPrimaryTrackGroupSelection = false;
+ for (TrackSelection trackSelection : trackSelections) {
+ TrackGroup trackSelectionGroup = trackSelection.getTrackGroup();
+ int mainWrapperTrackGroupIndex = mainWrapperTrackGroups.indexOf(trackSelectionGroup);
+ if (mainWrapperTrackGroupIndex != C.INDEX_UNSET) {
+ if (mainWrapperTrackGroupIndex == mainWrapperPrimaryGroupIndex) {
+ // Primary group in main wrapper.
+ hasPrimaryTrackGroupSelection = true;
+ for (int i = 0; i < trackSelection.length(); i++) {
+ int variantIndex = mainWrapperVariantIndices[trackSelection.getIndexInTrackGroup(i)];
+ streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, variantIndex));
+ }
+ } else {
+ // Embedded group in main wrapper.
+ needsPrimaryTrackGroupSelection = true;
+ }
+ } else {
+ // Audio or subtitle group.
+ for (int i = audioWrapperOffset; i < sampleStreamWrappers.length; i++) {
+ TrackGroupArray wrapperTrackGroups = sampleStreamWrappers[i].getTrackGroups();
+ int selectedTrackGroupIndex = wrapperTrackGroups.indexOf(trackSelectionGroup);
+ if (selectedTrackGroupIndex != C.INDEX_UNSET) {
+ int groupIndexType =
+ i < subtitleWrapperOffset
+ ? HlsMasterPlaylist.GROUP_INDEX_AUDIO
+ : HlsMasterPlaylist.GROUP_INDEX_SUBTITLE;
+ int[] selectedWrapperUrlIndices = manifestUrlIndicesPerWrapper[i];
+ for (int trackIndex = 0; trackIndex < trackSelection.length(); trackIndex++) {
+ int renditionIndex =
+ selectedWrapperUrlIndices[trackSelection.getIndexInTrackGroup(trackIndex)];
+ streamKeys.add(new StreamKey(groupIndexType, renditionIndex));
+ }
+ break;
+ }
+ }
+ }
+ }
+ if (needsPrimaryTrackGroupSelection && !hasPrimaryTrackGroupSelection) {
+ // A track selection includes a variant-embedded track, but no variant is added yet. We use
+ // the valid variant with the lowest bitrate to reduce overhead.
+ int lowestBitrateIndex = mainWrapperVariantIndices[0];
+ int lowestBitrate = masterPlaylist.variants.get(mainWrapperVariantIndices[0]).format.bitrate;
+ for (int i = 1; i < mainWrapperVariantIndices.length; i++) {
+ int variantBitrate =
+ masterPlaylist.variants.get(mainWrapperVariantIndices[i]).format.bitrate;
+ if (variantBitrate < lowestBitrate) {
+ lowestBitrate = variantBitrate;
+ lowestBitrateIndex = mainWrapperVariantIndices[i];
+ }
+ }
+ streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, lowestBitrateIndex));
+ }
+ return streamKeys;
+ }
+
+ @Override
+ public long selectTracks(
+ @NullableType TrackSelection[] selections,
+ boolean[] mayRetainStreamFlags,
+ @NullableType SampleStream[] streams,
+ boolean[] streamResetFlags,
+ long positionUs) {
+ // Map each selection and stream onto a child period index.
+ int[] streamChildIndices = new int[selections.length];
+ int[] selectionChildIndices = new int[selections.length];
+ for (int i = 0; i < selections.length; i++) {
+ streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET
+ : streamWrapperIndices.get(streams[i]);
+ selectionChildIndices[i] = C.INDEX_UNSET;
+ if (selections[i] != null) {
+ TrackGroup trackGroup = selections[i].getTrackGroup();
+ for (int j = 0; j < sampleStreamWrappers.length; j++) {
+ if (sampleStreamWrappers[j].getTrackGroups().indexOf(trackGroup) != C.INDEX_UNSET) {
+ selectionChildIndices[i] = j;
+ break;
+ }
+ }
+ }
+ }
+
+ boolean forceReset = false;
+ streamWrapperIndices.clear();
+ // Select tracks for each child, copying the resulting streams back into a new streams array.
+ SampleStream[] newStreams = new SampleStream[selections.length];
+ @NullableType SampleStream[] childStreams = new SampleStream[selections.length];
+ @NullableType TrackSelection[] childSelections = new TrackSelection[selections.length];
+ int newEnabledSampleStreamWrapperCount = 0;
+ HlsSampleStreamWrapper[] newEnabledSampleStreamWrappers =
+ new HlsSampleStreamWrapper[sampleStreamWrappers.length];
+ for (int i = 0; i < sampleStreamWrappers.length; i++) {
+ for (int j = 0; j < selections.length; j++) {
+ childStreams[j] = streamChildIndices[j] == i ? streams[j] : null;
+ childSelections[j] = selectionChildIndices[j] == i ? selections[j] : null;
+ }
+ HlsSampleStreamWrapper sampleStreamWrapper = sampleStreamWrappers[i];
+ boolean wasReset = sampleStreamWrapper.selectTracks(childSelections, mayRetainStreamFlags,
+ childStreams, streamResetFlags, positionUs, forceReset);
+ boolean wrapperEnabled = false;
+ for (int j = 0; j < selections.length; j++) {
+ SampleStream childStream = childStreams[j];
+ if (selectionChildIndices[j] == i) {
+ // Assert that the child provided a stream for the selection.
+ Assertions.checkNotNull(childStream);
+ newStreams[j] = childStream;
+ wrapperEnabled = true;
+ streamWrapperIndices.put(childStream, i);
+ } else if (streamChildIndices[j] == i) {
+ // Assert that the child cleared any previous stream.
+ Assertions.checkState(childStream == null);
+ }
+ }
+ if (wrapperEnabled) {
+ newEnabledSampleStreamWrappers[newEnabledSampleStreamWrapperCount] = sampleStreamWrapper;
+ if (newEnabledSampleStreamWrapperCount++ == 0) {
+ // The first enabled wrapper is responsible for initializing timestamp adjusters. This
+ // way, if enabled, variants are responsible. Else audio renditions. Else text renditions.
+ sampleStreamWrapper.setIsTimestampMaster(true);
+ if (wasReset || enabledSampleStreamWrappers.length == 0
+ || sampleStreamWrapper != enabledSampleStreamWrappers[0]) {
+ // The wrapper responsible for initializing the timestamp adjusters was reset or
+ // changed. We need to reset the timestamp adjuster provider and all other wrappers.
+ timestampAdjusterProvider.reset();
+ forceReset = true;
+ }
+ } else {
+ sampleStreamWrapper.setIsTimestampMaster(false);
+ }
+ }
+ }
+ // Copy the new streams back into the streams array.
+ System.arraycopy(newStreams, 0, streams, 0, newStreams.length);
+ // Update the local state.
+ enabledSampleStreamWrappers =
+ Util.nullSafeArrayCopy(newEnabledSampleStreamWrappers, newEnabledSampleStreamWrapperCount);
+ compositeSequenceableLoader =
+ compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(
+ enabledSampleStreamWrappers);
+ return positionUs;
+ }
+
+ @Override
+ public void discardBuffer(long positionUs, boolean toKeyframe) {
+ for (HlsSampleStreamWrapper sampleStreamWrapper : enabledSampleStreamWrappers) {
+ sampleStreamWrapper.discardBuffer(positionUs, toKeyframe);
+ }
+ }
+
+ @Override
+ public void reevaluateBuffer(long positionUs) {
+ compositeSequenceableLoader.reevaluateBuffer(positionUs);
+ }
+
+ @Override
+ public boolean continueLoading(long positionUs) {
+ if (trackGroups == null) {
+ // Preparation is still going on.
+ for (HlsSampleStreamWrapper wrapper : sampleStreamWrappers) {
+ wrapper.continuePreparing();
+ }
+ return false;
+ } else {
+ return compositeSequenceableLoader.continueLoading(positionUs);
+ }
+ }
+
+ @Override
+ public boolean isLoading() {
+ return compositeSequenceableLoader.isLoading();
+ }
+
+ @Override
+ public long getNextLoadPositionUs() {
+ return compositeSequenceableLoader.getNextLoadPositionUs();
+ }
+
+ @Override
+ public long readDiscontinuity() {
+ if (!notifiedReadingStarted) {
+ eventDispatcher.readingStarted();
+ notifiedReadingStarted = true;
+ }
+ return C.TIME_UNSET;
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return compositeSequenceableLoader.getBufferedPositionUs();
+ }
+
+ @Override
+ public long seekToUs(long positionUs) {
+ if (enabledSampleStreamWrappers.length > 0) {
+ // We need to reset all wrappers if the one responsible for initializing timestamp adjusters
+ // is reset. Else each wrapper can decide whether to reset independently.
+ boolean forceReset = enabledSampleStreamWrappers[0].seekToUs(positionUs, false);
+ for (int i = 1; i < enabledSampleStreamWrappers.length; i++) {
+ enabledSampleStreamWrappers[i].seekToUs(positionUs, forceReset);
+ }
+ if (forceReset) {
+ timestampAdjusterProvider.reset();
+ }
+ }
+ return positionUs;
+ }
+
+ @Override
+ public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
+ return positionUs;
+ }
+
+ // HlsSampleStreamWrapper.Callback implementation.
+
+ @Override
+ public void onPrepared() {
+ if (--pendingPrepareCount > 0) {
+ return;
+ }
+
+ int totalTrackGroupCount = 0;
+ for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) {
+ totalTrackGroupCount += sampleStreamWrapper.getTrackGroups().length;
+ }
+ TrackGroup[] trackGroupArray = new TrackGroup[totalTrackGroupCount];
+ int trackGroupIndex = 0;
+ for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) {
+ int wrapperTrackGroupCount = sampleStreamWrapper.getTrackGroups().length;
+ for (int j = 0; j < wrapperTrackGroupCount; j++) {
+ trackGroupArray[trackGroupIndex++] = sampleStreamWrapper.getTrackGroups().get(j);
+ }
+ }
+ trackGroups = new TrackGroupArray(trackGroupArray);
+ callback.onPrepared(this);
+ }
+
+ @Override
+ public void onPlaylistRefreshRequired(Uri url) {
+ playlistTracker.refreshPlaylist(url);
+ }
+
+ @Override
+ public void onContinueLoadingRequested(HlsSampleStreamWrapper sampleStreamWrapper) {
+ callback.onContinueLoadingRequested(this);
+ }
+
+ // PlaylistListener implementation.
+
+ @Override
+ public void onPlaylistChanged() {
+ callback.onContinueLoadingRequested(this);
+ }
+
+ @Override
+ public boolean onPlaylistError(Uri url, long blacklistDurationMs) {
+ boolean noBlacklistingFailure = true;
+ for (HlsSampleStreamWrapper streamWrapper : sampleStreamWrappers) {
+ noBlacklistingFailure &= streamWrapper.onPlaylistError(url, blacklistDurationMs);
+ }
+ callback.onContinueLoadingRequested(this);
+ return noBlacklistingFailure;
+ }
+
+ // Internal methods.
+
+ private void buildAndPrepareSampleStreamWrappers(long positionUs) {
+ HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist());
+ Map<String, DrmInitData> overridingDrmInitData =
+ useSessionKeys
+ ? deriveOverridingDrmInitData(masterPlaylist.sessionKeyDrmInitData)
+ : Collections.emptyMap();
+
+ boolean hasVariants = !masterPlaylist.variants.isEmpty();
+ List<Rendition> audioRenditions = masterPlaylist.audios;
+ List<Rendition> subtitleRenditions = masterPlaylist.subtitles;
+
+ pendingPrepareCount = 0;
+ ArrayList<HlsSampleStreamWrapper> sampleStreamWrappers = new ArrayList<>();
+ ArrayList<int[]> manifestUrlIndicesPerWrapper = new ArrayList<>();
+
+ if (hasVariants) {
+ buildAndPrepareMainSampleStreamWrapper(
+ masterPlaylist,
+ positionUs,
+ sampleStreamWrappers,
+ manifestUrlIndicesPerWrapper,
+ overridingDrmInitData);
+ }
+
+ // TODO: Build video stream wrappers here.
+
+ buildAndPrepareAudioSampleStreamWrappers(
+ positionUs,
+ audioRenditions,
+ sampleStreamWrappers,
+ manifestUrlIndicesPerWrapper,
+ overridingDrmInitData);
+
+ // Subtitle stream wrappers. We can always use master playlist information to prepare these.
+ for (int i = 0; i < subtitleRenditions.size(); i++) {
+ Rendition subtitleRendition = subtitleRenditions.get(i);
+ HlsSampleStreamWrapper sampleStreamWrapper =
+ buildSampleStreamWrapper(
+ C.TRACK_TYPE_TEXT,
+ new Uri[] {subtitleRendition.url},
+ new Format[] {subtitleRendition.format},
+ null,
+ Collections.emptyList(),
+ overridingDrmInitData,
+ positionUs);
+ manifestUrlIndicesPerWrapper.add(new int[] {i});
+ sampleStreamWrappers.add(sampleStreamWrapper);
+ sampleStreamWrapper.prepareWithMasterPlaylistInfo(
+ new TrackGroup[] {new TrackGroup(subtitleRendition.format)},
+ /* primaryTrackGroupIndex= */ 0);
+ }
+
+ this.sampleStreamWrappers = sampleStreamWrappers.toArray(new HlsSampleStreamWrapper[0]);
+ this.manifestUrlIndicesPerWrapper = manifestUrlIndicesPerWrapper.toArray(new int[0][]);
+ pendingPrepareCount = this.sampleStreamWrappers.length;
+ // Set timestamp master and trigger preparation (if not already prepared)
+ this.sampleStreamWrappers[0].setIsTimestampMaster(true);
+ for (HlsSampleStreamWrapper sampleStreamWrapper : this.sampleStreamWrappers) {
+ sampleStreamWrapper.continuePreparing();
+ }
+ // All wrappers are enabled during preparation.
+ enabledSampleStreamWrappers = this.sampleStreamWrappers;
+ }
+
+ /**
+ * This method creates and starts preparation of the main {@link HlsSampleStreamWrapper}.
+ *
+ * <p>The main sample stream wrapper is the first element of {@link #sampleStreamWrappers}. It
+ * provides {@link SampleStream}s for the variant urls in the master playlist. It may be adaptive
+ * and may contain multiple muxed tracks.
+ *
+ * <p>If chunkless preparation is allowed, the media period will try preparation without segment
+ * downloads. This is only possible if variants contain the CODECS attribute. If not, traditional
+ * preparation with segment downloads will take place. The following points apply to chunkless
+ * preparation:
+ *
+ * <ul>
+ * <li>A muxed audio track will be exposed if the codecs list contain an audio entry and the
+ * master playlist either contains an EXT-X-MEDIA tag without the URI attribute or does not
+ * contain any EXT-X-MEDIA tag.
+ * <li>Closed captions will only be exposed if they are declared by the master playlist.
+ * <li>An ID3 track is exposed preemptively, in case the segments contain an ID3 track.
+ * </ul>
+ *
+ * @param masterPlaylist The HLS master playlist.
+ * @param positionUs If preparation requires any chunk downloads, the position in microseconds at
+ * which downloading should start. Ignored otherwise.
+ * @param sampleStreamWrappers List to which the built main sample stream wrapper should be added.
+ * @param manifestUrlIndicesPerWrapper List to which the selected variant indices should be added.
+ * @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type
+ * (i.e. {@link DrmInitData#schemeType}).
+ */
+ private void buildAndPrepareMainSampleStreamWrapper(
+ HlsMasterPlaylist masterPlaylist,
+ long positionUs,
+ List<HlsSampleStreamWrapper> sampleStreamWrappers,
+ List<int[]> manifestUrlIndicesPerWrapper,
+ Map<String, DrmInitData> overridingDrmInitData) {
+ int[] variantTypes = new int[masterPlaylist.variants.size()];
+ int videoVariantCount = 0;
+ int audioVariantCount = 0;
+ for (int i = 0; i < masterPlaylist.variants.size(); i++) {
+ Variant variant = masterPlaylist.variants.get(i);
+ Format format = variant.format;
+ if (format.height > 0 || Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_VIDEO) != null) {
+ variantTypes[i] = C.TRACK_TYPE_VIDEO;
+ videoVariantCount++;
+ } else if (Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_AUDIO) != null) {
+ variantTypes[i] = C.TRACK_TYPE_AUDIO;
+ audioVariantCount++;
+ } else {
+ variantTypes[i] = C.TRACK_TYPE_UNKNOWN;
+ }
+ }
+ boolean useVideoVariantsOnly = false;
+ boolean useNonAudioVariantsOnly = false;
+ int selectedVariantsCount = variantTypes.length;
+ if (videoVariantCount > 0) {
+ // We've identified some variants as definitely containing video. Assume variants within the
+ // master playlist are marked consistently, and hence that we have the full set. Filter out
+ // any other variants, which are likely to be audio only.
+ useVideoVariantsOnly = true;
+ selectedVariantsCount = videoVariantCount;
+ } else if (audioVariantCount < variantTypes.length) {
+ // We've identified some variants, but not all, as being audio only. Filter them out to leave
+ // the remaining variants, which are likely to contain video.
+ useNonAudioVariantsOnly = true;
+ selectedVariantsCount = variantTypes.length - audioVariantCount;
+ }
+ Uri[] selectedPlaylistUrls = new Uri[selectedVariantsCount];
+ Format[] selectedPlaylistFormats = new Format[selectedVariantsCount];
+ int[] selectedVariantIndices = new int[selectedVariantsCount];
+ int outIndex = 0;
+ for (int i = 0; i < masterPlaylist.variants.size(); i++) {
+ if ((!useVideoVariantsOnly || variantTypes[i] == C.TRACK_TYPE_VIDEO)
+ && (!useNonAudioVariantsOnly || variantTypes[i] != C.TRACK_TYPE_AUDIO)) {
+ Variant variant = masterPlaylist.variants.get(i);
+ selectedPlaylistUrls[outIndex] = variant.url;
+ selectedPlaylistFormats[outIndex] = variant.format;
+ selectedVariantIndices[outIndex++] = i;
+ }
+ }
+ String codecs = selectedPlaylistFormats[0].codecs;
+ HlsSampleStreamWrapper sampleStreamWrapper =
+ buildSampleStreamWrapper(
+ C.TRACK_TYPE_DEFAULT,
+ selectedPlaylistUrls,
+ selectedPlaylistFormats,
+ masterPlaylist.muxedAudioFormat,
+ masterPlaylist.muxedCaptionFormats,
+ overridingDrmInitData,
+ positionUs);
+ sampleStreamWrappers.add(sampleStreamWrapper);
+ manifestUrlIndicesPerWrapper.add(selectedVariantIndices);
+ if (allowChunklessPreparation && codecs != null) {
+ boolean variantsContainVideoCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_VIDEO) != null;
+ boolean variantsContainAudioCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_AUDIO) != null;
+ List<TrackGroup> muxedTrackGroups = new ArrayList<>();
+ if (variantsContainVideoCodecs) {
+ Format[] videoFormats = new Format[selectedVariantsCount];
+ for (int i = 0; i < videoFormats.length; i++) {
+ videoFormats[i] = deriveVideoFormat(selectedPlaylistFormats[i]);
+ }
+ muxedTrackGroups.add(new TrackGroup(videoFormats));
+
+ if (variantsContainAudioCodecs
+ && (masterPlaylist.muxedAudioFormat != null || masterPlaylist.audios.isEmpty())) {
+ muxedTrackGroups.add(
+ new TrackGroup(
+ deriveAudioFormat(
+ selectedPlaylistFormats[0],
+ masterPlaylist.muxedAudioFormat,
+ /* isPrimaryTrackInVariant= */ false)));
+ }
+ List<Format> ccFormats = masterPlaylist.muxedCaptionFormats;
+ if (ccFormats != null) {
+ for (int i = 0; i < ccFormats.size(); i++) {
+ muxedTrackGroups.add(new TrackGroup(ccFormats.get(i)));
+ }
+ }
+ } else if (variantsContainAudioCodecs) {
+ // Variants only contain audio.
+ Format[] audioFormats = new Format[selectedVariantsCount];
+ for (int i = 0; i < audioFormats.length; i++) {
+ audioFormats[i] =
+ deriveAudioFormat(
+ /* variantFormat= */ selectedPlaylistFormats[i],
+ masterPlaylist.muxedAudioFormat,
+ /* isPrimaryTrackInVariant= */ true);
+ }
+ muxedTrackGroups.add(new TrackGroup(audioFormats));
+ } else {
+ // Variants contain codecs but no video or audio entries could be identified.
+ throw new IllegalArgumentException("Unexpected codecs attribute: " + codecs);
+ }
+
+ TrackGroup id3TrackGroup =
+ new TrackGroup(
+ Format.createSampleFormat(
+ /* id= */ "ID3",
+ MimeTypes.APPLICATION_ID3,
+ /* codecs= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ /* drmInitData= */ null));
+ muxedTrackGroups.add(id3TrackGroup);
+
+ sampleStreamWrapper.prepareWithMasterPlaylistInfo(
+ muxedTrackGroups.toArray(new TrackGroup[0]),
+ /* primaryTrackGroupIndex= */ 0,
+ /* optionalTrackGroupsIndices= */ muxedTrackGroups.indexOf(id3TrackGroup));
+ }
+ }
+
+ private void buildAndPrepareAudioSampleStreamWrappers(
+ long positionUs,
+ List<Rendition> audioRenditions,
+ List<HlsSampleStreamWrapper> sampleStreamWrappers,
+ List<int[]> manifestUrlsIndicesPerWrapper,
+ Map<String, DrmInitData> overridingDrmInitData) {
+ ArrayList<Uri> scratchPlaylistUrls =
+ new ArrayList<>(/* initialCapacity= */ audioRenditions.size());
+ ArrayList<Format> scratchPlaylistFormats =
+ new ArrayList<>(/* initialCapacity= */ audioRenditions.size());
+ ArrayList<Integer> scratchIndicesList =
+ new ArrayList<>(/* initialCapacity= */ audioRenditions.size());
+ HashSet<String> alreadyGroupedNames = new HashSet<>();
+ for (int renditionByNameIndex = 0;
+ renditionByNameIndex < audioRenditions.size();
+ renditionByNameIndex++) {
+ String name = audioRenditions.get(renditionByNameIndex).name;
+ if (!alreadyGroupedNames.add(name)) {
+ // This name already has a corresponding group.
+ continue;
+ }
+
+ boolean renditionsHaveCodecs = true;
+ scratchPlaylistUrls.clear();
+ scratchPlaylistFormats.clear();
+ scratchIndicesList.clear();
+ // Group all renditions with matching name.
+ for (int renditionIndex = 0; renditionIndex < audioRenditions.size(); renditionIndex++) {
+ if (Util.areEqual(name, audioRenditions.get(renditionIndex).name)) {
+ Rendition rendition = audioRenditions.get(renditionIndex);
+ scratchIndicesList.add(renditionIndex);
+ scratchPlaylistUrls.add(rendition.url);
+ scratchPlaylistFormats.add(rendition.format);
+ renditionsHaveCodecs &= rendition.format.codecs != null;
+ }
+ }
+
+ HlsSampleStreamWrapper sampleStreamWrapper =
+ buildSampleStreamWrapper(
+ C.TRACK_TYPE_AUDIO,
+ scratchPlaylistUrls.toArray(Util.castNonNullTypeArray(new Uri[0])),
+ scratchPlaylistFormats.toArray(new Format[0]),
+ /* muxedAudioFormat= */ null,
+ /* muxedCaptionFormats= */ Collections.emptyList(),
+ overridingDrmInitData,
+ positionUs);
+ manifestUrlsIndicesPerWrapper.add(Util.toArray(scratchIndicesList));
+ sampleStreamWrappers.add(sampleStreamWrapper);
+
+ if (allowChunklessPreparation && renditionsHaveCodecs) {
+ Format[] renditionFormats = scratchPlaylistFormats.toArray(new Format[0]);
+ sampleStreamWrapper.prepareWithMasterPlaylistInfo(
+ new TrackGroup[] {new TrackGroup(renditionFormats)}, /* primaryTrackGroupIndex= */ 0);
+ }
+ }
+ }
+
+ private HlsSampleStreamWrapper buildSampleStreamWrapper(
+ int trackType,
+ Uri[] playlistUrls,
+ Format[] playlistFormats,
+ @Nullable Format muxedAudioFormat,
+ @Nullable List<Format> muxedCaptionFormats,
+ Map<String, DrmInitData> overridingDrmInitData,
+ long positionUs) {
+ HlsChunkSource defaultChunkSource =
+ new HlsChunkSource(
+ extractorFactory,
+ playlistTracker,
+ playlistUrls,
+ playlistFormats,
+ dataSourceFactory,
+ mediaTransferListener,
+ timestampAdjusterProvider,
+ muxedCaptionFormats);
+ return new HlsSampleStreamWrapper(
+ trackType,
+ /* callback= */ this,
+ defaultChunkSource,
+ overridingDrmInitData,
+ allocator,
+ positionUs,
+ muxedAudioFormat,
+ drmSessionManager,
+ loadErrorHandlingPolicy,
+ eventDispatcher,
+ metadataType);
+ }
+
+ private static Map<String, DrmInitData> deriveOverridingDrmInitData(
+ List<DrmInitData> sessionKeyDrmInitData) {
+ ArrayList<DrmInitData> mutableSessionKeyDrmInitData = new ArrayList<>(sessionKeyDrmInitData);
+ HashMap<String, DrmInitData> drmInitDataBySchemeType = new HashMap<>();
+ for (int i = 0; i < mutableSessionKeyDrmInitData.size(); i++) {
+ DrmInitData drmInitData = sessionKeyDrmInitData.get(i);
+ String scheme = drmInitData.schemeType;
+ // Merge any subsequent drmInitData instances that have the same scheme type. This is valid
+ // due to the assumptions documented on HlsMediaSource.Builder.setUseSessionKeys, and is
+ // necessary to get data for different CDNs (e.g. Widevine and PlayReady) into a single
+ // drmInitData.
+ int j = i + 1;
+ while (j < mutableSessionKeyDrmInitData.size()) {
+ DrmInitData nextDrmInitData = mutableSessionKeyDrmInitData.get(j);
+ if (TextUtils.equals(nextDrmInitData.schemeType, scheme)) {
+ drmInitData = drmInitData.merge(nextDrmInitData);
+ mutableSessionKeyDrmInitData.remove(j);
+ } else {
+ j++;
+ }
+ }
+ drmInitDataBySchemeType.put(scheme, drmInitData);
+ }
+ return drmInitDataBySchemeType;
+ }
+
+ private static Format deriveVideoFormat(Format variantFormat) {
+ String codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO);
+ String sampleMimeType = MimeTypes.getMediaMimeType(codecs);
+ return Format.createVideoContainerFormat(
+ variantFormat.id,
+ variantFormat.label,
+ variantFormat.containerMimeType,
+ sampleMimeType,
+ codecs,
+ variantFormat.metadata,
+ variantFormat.bitrate,
+ variantFormat.width,
+ variantFormat.height,
+ variantFormat.frameRate,
+ /* initializationData= */ null,
+ variantFormat.selectionFlags,
+ variantFormat.roleFlags);
+ }
+
+ private static Format deriveAudioFormat(
+ Format variantFormat, @Nullable Format mediaTagFormat, boolean isPrimaryTrackInVariant) {
+ String codecs;
+ Metadata metadata;
+ int channelCount = Format.NO_VALUE;
+ int selectionFlags = 0;
+ int roleFlags = 0;
+ String language = null;
+ String label = null;
+ if (mediaTagFormat != null) {
+ codecs = mediaTagFormat.codecs;
+ metadata = mediaTagFormat.metadata;
+ channelCount = mediaTagFormat.channelCount;
+ selectionFlags = mediaTagFormat.selectionFlags;
+ roleFlags = mediaTagFormat.roleFlags;
+ language = mediaTagFormat.language;
+ label = mediaTagFormat.label;
+ } else {
+ codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_AUDIO);
+ metadata = variantFormat.metadata;
+ if (isPrimaryTrackInVariant) {
+ channelCount = variantFormat.channelCount;
+ selectionFlags = variantFormat.selectionFlags;
+ roleFlags = variantFormat.roleFlags;
+ language = variantFormat.language;
+ label = variantFormat.label;
+ }
+ }
+ String sampleMimeType = MimeTypes.getMediaMimeType(codecs);
+ int bitrate = isPrimaryTrackInVariant ? variantFormat.bitrate : Format.NO_VALUE;
+ return Format.createAudioContainerFormat(
+ variantFormat.id,
+ label,
+ variantFormat.containerMimeType,
+ sampleMimeType,
+ codecs,
+ metadata,
+ bitrate,
+ channelCount,
+ /* sampleRate= */ Format.NO_VALUE,
+ /* initializationData= */ null,
+ selectionFlags,
+ roleFlags,
+ language);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java
new file mode 100644
index 0000000000..2fa49e13f0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java
@@ -0,0 +1,528 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.net.Uri;
+import android.os.Handler;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.BaseMediaSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SinglePeriodTimeline;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistParserFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.FilteringHlsPlaylistParserFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.io.IOException;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.util.List;
+
+/** An HLS {@link MediaSource}. */
+public final class HlsMediaSource extends BaseMediaSource
+ implements HlsPlaylistTracker.PrimaryPlaylistListener {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.hls");
+ }
+
+ /**
+ * The types of metadata that can be extracted from HLS streams.
+ *
+ * <p>Allowed values:
+ *
+ * <ul>
+ * <li>{@link #METADATA_TYPE_ID3}
+ * <li>{@link #METADATA_TYPE_EMSG}
+ * </ul>
+ *
+ * <p>See {@link Factory#setMetadataType(int)}.
+ */
+ @Documented
+ @Retention(SOURCE)
+ @IntDef({METADATA_TYPE_ID3, METADATA_TYPE_EMSG})
+ public @interface MetadataType {}
+
+ /** Type for ID3 metadata in HLS streams. */
+ public static final int METADATA_TYPE_ID3 = 1;
+ /** Type for ESMG metadata in HLS streams. */
+ public static final int METADATA_TYPE_EMSG = 3;
+
+ /** Factory for {@link HlsMediaSource}s. */
+ public static final class Factory implements MediaSourceFactory {
+
+ private final HlsDataSourceFactory hlsDataSourceFactory;
+
+ private HlsExtractorFactory extractorFactory;
+ private HlsPlaylistParserFactory playlistParserFactory;
+ @Nullable private List<StreamKey> streamKeys;
+ private HlsPlaylistTracker.Factory playlistTrackerFactory;
+ private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
+ private DrmSessionManager<?> drmSessionManager;
+ private LoadErrorHandlingPolicy loadErrorHandlingPolicy;
+ private boolean allowChunklessPreparation;
+ @MetadataType private int metadataType;
+ private boolean useSessionKeys;
+ private boolean isCreateCalled;
+ @Nullable private Object tag;
+
+ /**
+ * Creates a new factory for {@link HlsMediaSource}s.
+ *
+ * @param dataSourceFactory A data source factory that will be wrapped by a {@link
+ * DefaultHlsDataSourceFactory} to create {@link DataSource}s for manifests, segments and
+ * keys.
+ */
+ public Factory(DataSource.Factory dataSourceFactory) {
+ this(new DefaultHlsDataSourceFactory(dataSourceFactory));
+ }
+
+ /**
+ * Creates a new factory for {@link HlsMediaSource}s.
+ *
+ * @param hlsDataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for
+ * manifests, segments and keys.
+ */
+ public Factory(HlsDataSourceFactory hlsDataSourceFactory) {
+ this.hlsDataSourceFactory = Assertions.checkNotNull(hlsDataSourceFactory);
+ playlistParserFactory = new DefaultHlsPlaylistParserFactory();
+ playlistTrackerFactory = DefaultHlsPlaylistTracker.FACTORY;
+ extractorFactory = HlsExtractorFactory.DEFAULT;
+ drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
+ loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy();
+ compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory();
+ metadataType = METADATA_TYPE_ID3;
+ }
+
+ /**
+ * Sets a tag for the media source which will be published in the {@link
+ * org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline} of the source as {@link
+ * org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window#tag}.
+ *
+ * @param tag A tag for the media source.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setTag(@Nullable Object tag) {
+ Assertions.checkState(!isCreateCalled);
+ this.tag = tag;
+ return this;
+ }
+
+ /**
+ * Sets the factory for {@link Extractor}s for the segments. The default value is {@link
+ * HlsExtractorFactory#DEFAULT}.
+ *
+ * @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the
+ * segments.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setExtractorFactory(HlsExtractorFactory extractorFactory) {
+ Assertions.checkState(!isCreateCalled);
+ this.extractorFactory = Assertions.checkNotNull(extractorFactory);
+ return this;
+ }
+
+ /**
+ * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link
+ * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}.
+ *
+ * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}.
+ *
+ * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
+ Assertions.checkState(!isCreateCalled);
+ this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
+ return this;
+ }
+
+ /**
+ * Sets the minimum number of times to retry if a loading error occurs. The default value is
+ * {@link DefaultLoadErrorHandlingPolicy#DEFAULT_MIN_LOADABLE_RETRY_COUNT}.
+ *
+ * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with
+ * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int)
+ * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)}
+ *
+ * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead.
+ */
+ @Deprecated
+ public Factory setMinLoadableRetryCount(int minLoadableRetryCount) {
+ Assertions.checkState(!isCreateCalled);
+ this.loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount);
+ return this;
+ }
+
+ /**
+ * Sets the factory from which playlist parsers will be obtained. The default value is a {@link
+ * DefaultHlsPlaylistParserFactory}.
+ *
+ * @param playlistParserFactory An {@link HlsPlaylistParserFactory}.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setPlaylistParserFactory(HlsPlaylistParserFactory playlistParserFactory) {
+ Assertions.checkState(!isCreateCalled);
+ this.playlistParserFactory = Assertions.checkNotNull(playlistParserFactory);
+ return this;
+ }
+
+ /**
+ * Sets the {@link HlsPlaylistTracker} factory. The default value is {@link
+ * DefaultHlsPlaylistTracker#FACTORY}.
+ *
+ * @param playlistTrackerFactory A factory for {@link HlsPlaylistTracker} instances.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setPlaylistTrackerFactory(HlsPlaylistTracker.Factory playlistTrackerFactory) {
+ Assertions.checkState(!isCreateCalled);
+ this.playlistTrackerFactory = Assertions.checkNotNull(playlistTrackerFactory);
+ return this;
+ }
+
+ /**
+ * Sets the factory to create composite {@link SequenceableLoader}s for when this media source
+ * loads data from multiple streams (video, audio etc...). The default is an instance of {@link
+ * DefaultCompositeSequenceableLoaderFactory}.
+ *
+ * @param compositeSequenceableLoaderFactory A factory to create composite {@link
+ * SequenceableLoader}s for when this media source loads data from multiple streams (video,
+ * audio etc...).
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setCompositeSequenceableLoaderFactory(
+ CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory) {
+ Assertions.checkState(!isCreateCalled);
+ this.compositeSequenceableLoaderFactory =
+ Assertions.checkNotNull(compositeSequenceableLoaderFactory);
+ return this;
+ }
+
+ /**
+ * Sets whether chunkless preparation is allowed. If true, preparation without chunk downloads
+ * will be enabled for streams that provide sufficient information in their master playlist.
+ *
+ * @param allowChunklessPreparation Whether chunkless preparation is allowed.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ public Factory setAllowChunklessPreparation(boolean allowChunklessPreparation) {
+ Assertions.checkState(!isCreateCalled);
+ this.allowChunklessPreparation = allowChunklessPreparation;
+ return this;
+ }
+
+ /**
+ * Sets the type of metadata to extract from the HLS source (defaults to {@link
+ * #METADATA_TYPE_ID3}).
+ *
+ * <p>HLS supports in-band ID3 in both TS and fMP4 streams, but in the fMP4 case the data is
+ * wrapped in an EMSG box [<a href="https://aomediacodec.github.io/av1-id3/">spec</a>].
+ *
+ * <p>If this is set to {@link #METADATA_TYPE_ID3} then raw ID3 metadata of will be extracted
+ * from TS sources. From fMP4 streams EMSGs containing metadata of this type (in the variant
+ * stream only) will be unwrapped to expose the inner data. All other in-band metadata will be
+ * dropped.
+ *
+ * <p>If this is set to {@link #METADATA_TYPE_EMSG} then all EMSG data from the fMP4 variant
+ * stream will be extracted. No metadata will be extracted from TS streams, since they don't
+ * support EMSG.
+ *
+ * @param metadataType The type of metadata to extract.
+ * @return This factory, for convenience.
+ */
+ public Factory setMetadataType(@MetadataType int metadataType) {
+ Assertions.checkState(!isCreateCalled);
+ this.metadataType = metadataType;
+ return this;
+ }
+
+ /**
+ * Sets whether to use #EXT-X-SESSION-KEY tags provided in the master playlist. If enabled, it's
+ * assumed that any single session key declared in the master playlist can be used to obtain all
+ * of the keys required for playback. For media where this is not true, this option should not
+ * be enabled.
+ *
+ * @param useSessionKeys Whether to use #EXT-X-SESSION-KEY tags.
+ * @return This factory, for convenience.
+ */
+ public Factory setUseSessionKeys(boolean useSessionKeys) {
+ this.useSessionKeys = useSessionKeys;
+ return this;
+ }
+
+ /**
+ * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler,
+ * MediaSourceEventListener)} instead.
+ */
+ @Deprecated
+ public HlsMediaSource createMediaSource(
+ Uri playlistUri,
+ @Nullable Handler eventHandler,
+ @Nullable MediaSourceEventListener eventListener) {
+ HlsMediaSource mediaSource = createMediaSource(playlistUri);
+ if (eventHandler != null && eventListener != null) {
+ mediaSource.addEventListener(eventHandler, eventListener);
+ }
+ return mediaSource;
+ }
+
+ /**
+ * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The
+ * default value is {@link DrmSessionManager#DUMMY}.
+ *
+ * @param drmSessionManager The {@link DrmSessionManager}.
+ * @return This factory, for convenience.
+ * @throws IllegalStateException If one of the {@code create} methods has already been called.
+ */
+ @Override
+ public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) {
+ Assertions.checkState(!isCreateCalled);
+ this.drmSessionManager = drmSessionManager;
+ return this;
+ }
+
+ /**
+ * Returns a new {@link HlsMediaSource} using the current parameters.
+ *
+ * @return The new {@link HlsMediaSource}.
+ */
+ @Override
+ public HlsMediaSource createMediaSource(Uri playlistUri) {
+ isCreateCalled = true;
+ if (streamKeys != null) {
+ playlistParserFactory =
+ new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys);
+ }
+ return new HlsMediaSource(
+ playlistUri,
+ hlsDataSourceFactory,
+ extractorFactory,
+ compositeSequenceableLoaderFactory,
+ drmSessionManager,
+ loadErrorHandlingPolicy,
+ playlistTrackerFactory.createTracker(
+ hlsDataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory),
+ allowChunklessPreparation,
+ metadataType,
+ useSessionKeys,
+ tag);
+ }
+
+ @Override
+ public Factory setStreamKeys(List<StreamKey> streamKeys) {
+ Assertions.checkState(!isCreateCalled);
+ this.streamKeys = streamKeys;
+ return this;
+ }
+
+ @Override
+ public int[] getSupportedTypes() {
+ return new int[] {C.TYPE_HLS};
+ }
+
+ }
+
+ private final HlsExtractorFactory extractorFactory;
+ private final Uri manifestUri;
+ private final HlsDataSourceFactory dataSourceFactory;
+ private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
+ private final DrmSessionManager<?> drmSessionManager;
+ private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
+ private final boolean allowChunklessPreparation;
+ private final @MetadataType int metadataType;
+ private final boolean useSessionKeys;
+ private final HlsPlaylistTracker playlistTracker;
+ @Nullable private final Object tag;
+
+ @Nullable private TransferListener mediaTransferListener;
+
+ private HlsMediaSource(
+ Uri manifestUri,
+ HlsDataSourceFactory dataSourceFactory,
+ HlsExtractorFactory extractorFactory,
+ CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
+ DrmSessionManager<?> drmSessionManager,
+ LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+ HlsPlaylistTracker playlistTracker,
+ boolean allowChunklessPreparation,
+ @MetadataType int metadataType,
+ boolean useSessionKeys,
+ @Nullable Object tag) {
+ this.manifestUri = manifestUri;
+ this.dataSourceFactory = dataSourceFactory;
+ this.extractorFactory = extractorFactory;
+ this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
+ this.drmSessionManager = drmSessionManager;
+ this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
+ this.playlistTracker = playlistTracker;
+ this.allowChunklessPreparation = allowChunklessPreparation;
+ this.metadataType = metadataType;
+ this.useSessionKeys = useSessionKeys;
+ this.tag = tag;
+ }
+
+ @Override
+ @Nullable
+ public Object getTag() {
+ return tag;
+ }
+
+ @Override
+ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
+ this.mediaTransferListener = mediaTransferListener;
+ drmSessionManager.prepare();
+ EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null);
+ playlistTracker.start(manifestUri, eventDispatcher, /* listener= */ this);
+ }
+
+ @Override
+ public void maybeThrowSourceInfoRefreshError() throws IOException {
+ playlistTracker.maybeThrowPrimaryPlaylistRefreshError();
+ }
+
+ @Override
+ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
+ EventDispatcher eventDispatcher = createEventDispatcher(id);
+ return new HlsMediaPeriod(
+ extractorFactory,
+ playlistTracker,
+ dataSourceFactory,
+ mediaTransferListener,
+ drmSessionManager,
+ loadErrorHandlingPolicy,
+ eventDispatcher,
+ allocator,
+ compositeSequenceableLoaderFactory,
+ allowChunklessPreparation,
+ metadataType,
+ useSessionKeys);
+ }
+
+ @Override
+ public void releasePeriod(MediaPeriod mediaPeriod) {
+ ((HlsMediaPeriod) mediaPeriod).release();
+ }
+
+ @Override
+ protected void releaseSourceInternal() {
+ playlistTracker.stop();
+ drmSessionManager.release();
+ }
+
+ @Override
+ public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) {
+ SinglePeriodTimeline timeline;
+ long windowStartTimeMs = playlist.hasProgramDateTime ? C.usToMs(playlist.startTimeUs)
+ : C.TIME_UNSET;
+ // For playlist types EVENT and VOD we know segments are never removed, so the presentation
+ // started at the same time as the window. Otherwise, we don't know the presentation start time.
+ long presentationStartTimeMs =
+ playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT
+ || playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD
+ ? windowStartTimeMs
+ : C.TIME_UNSET;
+ long windowDefaultStartPositionUs = playlist.startOffsetUs;
+ // masterPlaylist is non-null because the first playlist has been fetched by now.
+ HlsManifest manifest =
+ new HlsManifest(Assertions.checkNotNull(playlistTracker.getMasterPlaylist()), playlist);
+ if (playlistTracker.isLive()) {
+ long offsetFromInitialStartTimeUs =
+ playlist.startTimeUs - playlistTracker.getInitialStartTimeUs();
+ long periodDurationUs =
+ playlist.hasEndTag ? offsetFromInitialStartTimeUs + playlist.durationUs : C.TIME_UNSET;
+ List<HlsMediaPlaylist.Segment> segments = playlist.segments;
+ if (windowDefaultStartPositionUs == C.TIME_UNSET) {
+ windowDefaultStartPositionUs = 0;
+ if (!segments.isEmpty()) {
+ int defaultStartSegmentIndex = Math.max(0, segments.size() - 3);
+ // We attempt to set the default start position to be at least twice the target duration
+ // behind the live edge.
+ long minStartPositionUs = playlist.durationUs - playlist.targetDurationUs * 2;
+ while (defaultStartSegmentIndex > 0
+ && segments.get(defaultStartSegmentIndex).relativeStartTimeUs > minStartPositionUs) {
+ defaultStartSegmentIndex--;
+ }
+ windowDefaultStartPositionUs = segments.get(defaultStartSegmentIndex).relativeStartTimeUs;
+ }
+ }
+ timeline =
+ new SinglePeriodTimeline(
+ presentationStartTimeMs,
+ windowStartTimeMs,
+ periodDurationUs,
+ /* windowDurationUs= */ playlist.durationUs,
+ /* windowPositionInPeriodUs= */ offsetFromInitialStartTimeUs,
+ windowDefaultStartPositionUs,
+ /* isSeekable= */ true,
+ /* isDynamic= */ !playlist.hasEndTag,
+ /* isLive= */ true,
+ manifest,
+ tag);
+ } else /* not live */ {
+ if (windowDefaultStartPositionUs == C.TIME_UNSET) {
+ windowDefaultStartPositionUs = 0;
+ }
+ timeline =
+ new SinglePeriodTimeline(
+ presentationStartTimeMs,
+ windowStartTimeMs,
+ /* periodDurationUs= */ playlist.durationUs,
+ /* windowDurationUs= */ playlist.durationUs,
+ /* windowPositionInPeriodUs= */ 0,
+ windowDefaultStartPositionUs,
+ /* isSeekable= */ true,
+ /* isDynamic= */ false,
+ /* isLive= */ false,
+ manifest,
+ tag);
+ }
+ refreshSourceInfo(timeline);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java
new file mode 100644
index 0000000000..5f44810af5
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.io.IOException;
+
+/**
+ * {@link SampleStream} for a particular sample queue in HLS.
+ */
+/* package */ final class HlsSampleStream implements SampleStream {
+
+ private final int trackGroupIndex;
+ private final HlsSampleStreamWrapper sampleStreamWrapper;
+ private int sampleQueueIndex;
+
+ public HlsSampleStream(HlsSampleStreamWrapper sampleStreamWrapper, int trackGroupIndex) {
+ this.sampleStreamWrapper = sampleStreamWrapper;
+ this.trackGroupIndex = trackGroupIndex;
+ sampleQueueIndex = HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING;
+ }
+
+ public void bindSampleQueue() {
+ Assertions.checkArgument(sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING);
+ sampleQueueIndex = sampleStreamWrapper.bindSampleQueueToSampleStream(trackGroupIndex);
+ }
+
+ public void unbindSampleQueue() {
+ if (sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING) {
+ sampleStreamWrapper.unbindSampleQueue(trackGroupIndex);
+ sampleQueueIndex = HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING;
+ }
+ }
+
+ // SampleStream implementation.
+
+ @Override
+ public boolean isReady() {
+ return sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL
+ || (hasValidSampleQueueIndex() && sampleStreamWrapper.isReady(sampleQueueIndex));
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL) {
+ throw new SampleQueueMappingException(
+ sampleStreamWrapper.getTrackGroups().get(trackGroupIndex).getFormat(0).sampleMimeType);
+ } else if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING) {
+ sampleStreamWrapper.maybeThrowError();
+ } else if (sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL) {
+ sampleStreamWrapper.maybeThrowError(sampleQueueIndex);
+ }
+ }
+
+ @Override
+ public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean requireFormat) {
+ if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL) {
+ buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
+ return C.RESULT_BUFFER_READ;
+ }
+ return hasValidSampleQueueIndex()
+ ? sampleStreamWrapper.readData(sampleQueueIndex, formatHolder, buffer, requireFormat)
+ : C.RESULT_NOTHING_READ;
+ }
+
+ @Override
+ public int skipData(long positionUs) {
+ return hasValidSampleQueueIndex()
+ ? sampleStreamWrapper.skipData(sampleQueueIndex, positionUs)
+ : 0;
+ }
+
+ // Internal methods.
+
+ private boolean hasValidSampleQueueIndex() {
+ return sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING
+ && sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL
+ && sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java
new file mode 100644
index 0000000000..833abbc29f
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java
@@ -0,0 +1,1535 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.net.Uri;
+import android.os.Handler;
+import android.util.SparseIntArray;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessage;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.PrivFrame;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.Chunk;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.EOFException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * Loads {@link HlsMediaChunk}s obtained from a {@link HlsChunkSource}, and provides
+ * {@link SampleStream}s from which the loaded media can be consumed.
+ */
+/* package */ final class HlsSampleStreamWrapper implements Loader.Callback<Chunk>,
+ Loader.ReleaseCallback, SequenceableLoader, ExtractorOutput, UpstreamFormatChangedListener {
+
+ /**
+ * A callback to be notified of events.
+ */
+ public interface Callback extends SequenceableLoader.Callback<HlsSampleStreamWrapper> {
+
+ /**
+ * Called when the wrapper has been prepared.
+ *
+ * <p>Note: This method will be called on a later handler loop than the one on which either
+ * {@link #prepareWithMasterPlaylistInfo} or {@link #continuePreparing} are invoked.
+ */
+ void onPrepared();
+
+ /**
+ * Called to schedule a {@link #continueLoading(long)} call when the playlist referred by the
+ * given url changes.
+ */
+ void onPlaylistRefreshRequired(Uri playlistUrl);
+ }
+
+ private static final String TAG = "HlsSampleStreamWrapper";
+
+ public static final int SAMPLE_QUEUE_INDEX_PENDING = -1;
+ public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL = -2;
+ public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL = -3;
+
+ private static final Set<Integer> MAPPABLE_TYPES =
+ Collections.unmodifiableSet(
+ new HashSet<>(
+ Arrays.asList(C.TRACK_TYPE_AUDIO, C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_METADATA)));
+
+ private final int trackType;
+ private final Callback callback;
+ private final HlsChunkSource chunkSource;
+ private final Allocator allocator;
+ @Nullable private final Format muxedAudioFormat;
+ private final DrmSessionManager<?> drmSessionManager;
+ private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
+ private final Loader loader;
+ private final EventDispatcher eventDispatcher;
+ private final @HlsMediaSource.MetadataType int metadataType;
+ private final HlsChunkSource.HlsChunkHolder nextChunkHolder;
+ private final ArrayList<HlsMediaChunk> mediaChunks;
+ private final List<HlsMediaChunk> readOnlyMediaChunks;
+ // Using runnables rather than in-line method references to avoid repeated allocations.
+ private final Runnable maybeFinishPrepareRunnable;
+ private final Runnable onTracksEndedRunnable;
+ private final Handler handler;
+ private final ArrayList<HlsSampleStream> hlsSampleStreams;
+ private final Map<String, DrmInitData> overridingDrmInitData;
+
+ private FormatAdjustingSampleQueue[] sampleQueues;
+ private int[] sampleQueueTrackIds;
+ private Set<Integer> sampleQueueMappingDoneByType;
+ private SparseIntArray sampleQueueIndicesByType;
+ @MonotonicNonNull private TrackOutput emsgUnwrappingTrackOutput;
+ private int primarySampleQueueType;
+ private int primarySampleQueueIndex;
+ private boolean sampleQueuesBuilt;
+ private boolean prepared;
+ private int enabledTrackGroupCount;
+ @MonotonicNonNull private Format upstreamTrackFormat;
+ @Nullable private Format downstreamTrackFormat;
+ private boolean released;
+
+ // Tracks are complicated in HLS. See documentation of buildTracksFromSampleStreams for details.
+ // Indexed by track (as exposed by this source).
+ @MonotonicNonNull private TrackGroupArray trackGroups;
+ @MonotonicNonNull private Set<TrackGroup> optionalTrackGroups;
+ // Indexed by track group.
+ private int @MonotonicNonNull [] trackGroupToSampleQueueIndex;
+ private int primaryTrackGroupIndex;
+ private boolean haveAudioVideoSampleQueues;
+ private boolean[] sampleQueuesEnabledStates;
+ private boolean[] sampleQueueIsAudioVideoFlags;
+
+ private long lastSeekPositionUs;
+ private long pendingResetPositionUs;
+ private boolean pendingResetUpstreamFormats;
+ private boolean seenFirstTrackSelection;
+ private boolean loadingFinished;
+
+ // Accessed only by the loading thread.
+ private boolean tracksEnded;
+ private long sampleOffsetUs;
+ @Nullable private DrmInitData drmInitData;
+ private int chunkUid;
+
+ /**
+ * @param trackType The type of the track. One of the {@link C} {@code TRACK_TYPE_*} constants.
+ * @param callback A callback for the wrapper.
+ * @param chunkSource A {@link HlsChunkSource} from which chunks to load are obtained.
+ * @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type
+ * (i.e. {@link DrmInitData#schemeType}). If the stream has {@link DrmInitData} and uses a
+ * protection scheme type for which overriding {@link DrmInitData} is provided, then the
+ * stream's {@link DrmInitData} will be overridden.
+ * @param allocator An {@link Allocator} from which to obtain media buffer allocations.
+ * @param positionUs The position from which to start loading media.
+ * @param muxedAudioFormat Optional muxed audio {@link Format} as defined by the master playlist.
+ * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession
+ * DrmSessions} with.
+ * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}.
+ * @param eventDispatcher A dispatcher to notify of events.
+ */
+ public HlsSampleStreamWrapper(
+ int trackType,
+ Callback callback,
+ HlsChunkSource chunkSource,
+ Map<String, DrmInitData> overridingDrmInitData,
+ Allocator allocator,
+ long positionUs,
+ @Nullable Format muxedAudioFormat,
+ DrmSessionManager<?> drmSessionManager,
+ LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+ EventDispatcher eventDispatcher,
+ @HlsMediaSource.MetadataType int metadataType) {
+ this.trackType = trackType;
+ this.callback = callback;
+ this.chunkSource = chunkSource;
+ this.overridingDrmInitData = overridingDrmInitData;
+ this.allocator = allocator;
+ this.muxedAudioFormat = muxedAudioFormat;
+ this.drmSessionManager = drmSessionManager;
+ this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
+ this.eventDispatcher = eventDispatcher;
+ this.metadataType = metadataType;
+ loader = new Loader("Loader:HlsSampleStreamWrapper");
+ nextChunkHolder = new HlsChunkSource.HlsChunkHolder();
+ sampleQueueTrackIds = new int[0];
+ sampleQueueMappingDoneByType = new HashSet<>(MAPPABLE_TYPES.size());
+ sampleQueueIndicesByType = new SparseIntArray(MAPPABLE_TYPES.size());
+ sampleQueues = new FormatAdjustingSampleQueue[0];
+ sampleQueueIsAudioVideoFlags = new boolean[0];
+ sampleQueuesEnabledStates = new boolean[0];
+ mediaChunks = new ArrayList<>();
+ readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks);
+ hlsSampleStreams = new ArrayList<>();
+ // Suppressions are needed because `this` is not initialized here.
+ @SuppressWarnings("nullness:methodref.receiver.bound.invalid")
+ Runnable maybeFinishPrepareRunnable = this::maybeFinishPrepare;
+ this.maybeFinishPrepareRunnable = maybeFinishPrepareRunnable;
+ @SuppressWarnings("nullness:methodref.receiver.bound.invalid")
+ Runnable onTracksEndedRunnable = this::onTracksEnded;
+ this.onTracksEndedRunnable = onTracksEndedRunnable;
+ handler = new Handler();
+ lastSeekPositionUs = positionUs;
+ pendingResetPositionUs = positionUs;
+ }
+
+ public void continuePreparing() {
+ if (!prepared) {
+ continueLoading(lastSeekPositionUs);
+ }
+ }
+
+ /**
+ * Prepares the sample stream wrapper with master playlist information.
+ *
+ * @param trackGroups The {@link TrackGroup TrackGroups} to expose through {@link
+ * #getTrackGroups()}.
+ * @param primaryTrackGroupIndex The index of the adaptive track group.
+ * @param optionalTrackGroupsIndices The indices of any {@code trackGroups} that should not
+ * trigger a failure if not found in the media playlist's segments.
+ */
+ public void prepareWithMasterPlaylistInfo(
+ TrackGroup[] trackGroups, int primaryTrackGroupIndex, int... optionalTrackGroupsIndices) {
+ this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups);
+ optionalTrackGroups = new HashSet<>();
+ for (int optionalTrackGroupIndex : optionalTrackGroupsIndices) {
+ optionalTrackGroups.add(this.trackGroups.get(optionalTrackGroupIndex));
+ }
+ this.primaryTrackGroupIndex = primaryTrackGroupIndex;
+ handler.post(callback::onPrepared);
+ setIsPrepared();
+ }
+
+ public void maybeThrowPrepareError() throws IOException {
+ maybeThrowError();
+ if (loadingFinished && !prepared) {
+ throw new ParserException("Loading finished before preparation is complete.");
+ }
+ }
+
+ public TrackGroupArray getTrackGroups() {
+ assertIsPrepared();
+ return trackGroups;
+ }
+
+ public int getPrimaryTrackGroupIndex() {
+ return primaryTrackGroupIndex;
+ }
+
+ public int bindSampleQueueToSampleStream(int trackGroupIndex) {
+ assertIsPrepared();
+ Assertions.checkNotNull(trackGroupToSampleQueueIndex);
+
+ int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex];
+ if (sampleQueueIndex == C.INDEX_UNSET) {
+ return optionalTrackGroups.contains(trackGroups.get(trackGroupIndex))
+ ? SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL
+ : SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL;
+ }
+ if (sampleQueuesEnabledStates[sampleQueueIndex]) {
+ // This sample queue is already bound to a different sample stream.
+ return SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL;
+ }
+ sampleQueuesEnabledStates[sampleQueueIndex] = true;
+ return sampleQueueIndex;
+ }
+
+ public void unbindSampleQueue(int trackGroupIndex) {
+ assertIsPrepared();
+ Assertions.checkNotNull(trackGroupToSampleQueueIndex);
+ int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex];
+ Assertions.checkState(sampleQueuesEnabledStates[sampleQueueIndex]);
+ sampleQueuesEnabledStates[sampleQueueIndex] = false;
+ }
+
+ /**
+ * Called by the parent {@link HlsMediaPeriod} when a track selection occurs.
+ *
+ * @param selections The renderer track selections.
+ * @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained
+ * for each selection. A {@code true} value indicates that the selection is unchanged, and
+ * that the caller does not require that the sample stream be recreated.
+ * @param streams The existing sample streams, which will be updated to reflect the provided
+ * selections.
+ * @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that
+ * have been retained but with the requirement that the consuming renderer be reset.
+ * @param positionUs The current playback position in microseconds.
+ * @param forceReset If true then a reset is forced (i.e. a seek will be performed with in-buffer
+ * seeking disabled).
+ * @return Whether this wrapper requires the parent {@link HlsMediaPeriod} to perform a seek as
+ * part of the track selection.
+ */
+ public boolean selectTracks(
+ @NullableType TrackSelection[] selections,
+ boolean[] mayRetainStreamFlags,
+ @NullableType SampleStream[] streams,
+ boolean[] streamResetFlags,
+ long positionUs,
+ boolean forceReset) {
+ assertIsPrepared();
+ int oldEnabledTrackGroupCount = enabledTrackGroupCount;
+ // Deselect old tracks.
+ for (int i = 0; i < selections.length; i++) {
+ HlsSampleStream stream = (HlsSampleStream) streams[i];
+ if (stream != null && (selections[i] == null || !mayRetainStreamFlags[i])) {
+ enabledTrackGroupCount--;
+ stream.unbindSampleQueue();
+ streams[i] = null;
+ }
+ }
+ // We'll always need to seek if we're being forced to reset, or if this is a first selection to
+ // a position other than the one we started preparing with, or if we're making a selection
+ // having previously disabled all tracks.
+ boolean seekRequired =
+ forceReset
+ || (seenFirstTrackSelection
+ ? oldEnabledTrackGroupCount == 0
+ : positionUs != lastSeekPositionUs);
+ // Get the old (i.e. current before the loop below executes) primary track selection. The new
+ // primary selection will equal the old one unless it's changed in the loop.
+ TrackSelection oldPrimaryTrackSelection = chunkSource.getTrackSelection();
+ TrackSelection primaryTrackSelection = oldPrimaryTrackSelection;
+ // Select new tracks.
+ for (int i = 0; i < selections.length; i++) {
+ TrackSelection selection = selections[i];
+ if (selection == null) {
+ continue;
+ }
+ int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup());
+ if (trackGroupIndex == primaryTrackGroupIndex) {
+ primaryTrackSelection = selection;
+ chunkSource.setTrackSelection(selection);
+ }
+ if (streams[i] == null) {
+ enabledTrackGroupCount++;
+ streams[i] = new HlsSampleStream(this, trackGroupIndex);
+ streamResetFlags[i] = true;
+ if (trackGroupToSampleQueueIndex != null) {
+ ((HlsSampleStream) streams[i]).bindSampleQueue();
+ // If there's still a chance of avoiding a seek, try and seek within the sample queue.
+ if (!seekRequired) {
+ SampleQueue sampleQueue = sampleQueues[trackGroupToSampleQueueIndex[trackGroupIndex]];
+ // A seek can be avoided if we're able to seek to the current playback position in
+ // the sample queue, or if we haven't read anything from the queue since the previous
+ // seek (this case is common for sparse tracks such as metadata tracks). In all other
+ // cases a seek is required.
+ seekRequired =
+ !sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true)
+ && sampleQueue.getReadIndex() != 0;
+ }
+ }
+ }
+ }
+
+ if (enabledTrackGroupCount == 0) {
+ chunkSource.reset();
+ downstreamTrackFormat = null;
+ pendingResetUpstreamFormats = true;
+ mediaChunks.clear();
+ if (loader.isLoading()) {
+ if (sampleQueuesBuilt) {
+ // Discard as much as we can synchronously.
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.discardToEnd();
+ }
+ }
+ loader.cancelLoading();
+ } else {
+ resetSampleQueues();
+ }
+ } else {
+ if (!mediaChunks.isEmpty()
+ && !Util.areEqual(primaryTrackSelection, oldPrimaryTrackSelection)) {
+ // The primary track selection has changed and we have buffered media. The buffered media
+ // may need to be discarded.
+ boolean primarySampleQueueDirty = false;
+ if (!seenFirstTrackSelection) {
+ long bufferedDurationUs = positionUs < 0 ? -positionUs : 0;
+ HlsMediaChunk lastMediaChunk = getLastMediaChunk();
+ MediaChunkIterator[] mediaChunkIterators =
+ chunkSource.createMediaChunkIterators(lastMediaChunk, positionUs);
+ primaryTrackSelection.updateSelectedTrack(
+ positionUs,
+ bufferedDurationUs,
+ C.TIME_UNSET,
+ readOnlyMediaChunks,
+ mediaChunkIterators);
+ int chunkIndex = chunkSource.getTrackGroup().indexOf(lastMediaChunk.trackFormat);
+ if (primaryTrackSelection.getSelectedIndexInTrackGroup() != chunkIndex) {
+ // This is the first selection and the chunk loaded during preparation does not match
+ // the initially selected format.
+ primarySampleQueueDirty = true;
+ }
+ } else {
+ // The primary sample queue contains media buffered for the old primary track selection.
+ primarySampleQueueDirty = true;
+ }
+ if (primarySampleQueueDirty) {
+ forceReset = true;
+ seekRequired = true;
+ pendingResetUpstreamFormats = true;
+ }
+ }
+ if (seekRequired) {
+ seekToUs(positionUs, forceReset);
+ // We'll need to reset renderers consuming from all streams due to the seek.
+ for (int i = 0; i < streams.length; i++) {
+ if (streams[i] != null) {
+ streamResetFlags[i] = true;
+ }
+ }
+ }
+ }
+
+ updateSampleStreams(streams);
+ seenFirstTrackSelection = true;
+ return seekRequired;
+ }
+
+ public void discardBuffer(long positionUs, boolean toKeyframe) {
+ if (!sampleQueuesBuilt || isPendingReset()) {
+ return;
+ }
+ int sampleQueueCount = sampleQueues.length;
+ for (int i = 0; i < sampleQueueCount; i++) {
+ sampleQueues[i].discardTo(positionUs, toKeyframe, sampleQueuesEnabledStates[i]);
+ }
+ }
+
+ /**
+ * Attempts to seek to the specified position in microseconds.
+ *
+ * @param positionUs The seek position in microseconds.
+ * @param forceReset If true then a reset is forced (i.e. in-buffer seeking is disabled).
+ * @return Whether the wrapper was reset, meaning the wrapped sample queues were reset. If false,
+ * an in-buffer seek was performed.
+ */
+ public boolean seekToUs(long positionUs, boolean forceReset) {
+ lastSeekPositionUs = positionUs;
+ if (isPendingReset()) {
+ // A reset is already pending. We only need to update its position.
+ pendingResetPositionUs = positionUs;
+ return true;
+ }
+
+ // If we're not forced to reset, try and seek within the buffer.
+ if (sampleQueuesBuilt && !forceReset && seekInsideBufferUs(positionUs)) {
+ return false;
+ }
+
+ // We can't seek inside the buffer, and so need to reset.
+ pendingResetPositionUs = positionUs;
+ loadingFinished = false;
+ mediaChunks.clear();
+ if (loader.isLoading()) {
+ loader.cancelLoading();
+ } else {
+ loader.clearFatalError();
+ resetSampleQueues();
+ }
+ return true;
+ }
+
+ public void release() {
+ if (prepared) {
+ // Discard as much as we can synchronously. We only do this if we're prepared, since otherwise
+ // sampleQueues may still be being modified by the loading thread.
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.preRelease();
+ }
+ }
+ loader.release(this);
+ handler.removeCallbacksAndMessages(null);
+ released = true;
+ hlsSampleStreams.clear();
+ }
+
+ @Override
+ public void onLoaderReleased() {
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.release();
+ }
+ }
+
+ public void setIsTimestampMaster(boolean isTimestampMaster) {
+ chunkSource.setIsTimestampMaster(isTimestampMaster);
+ }
+
+ public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) {
+ return chunkSource.onPlaylistError(playlistUrl, blacklistDurationMs);
+ }
+
+ // SampleStream implementation.
+
+ public boolean isReady(int sampleQueueIndex) {
+ return !isPendingReset() && sampleQueues[sampleQueueIndex].isReady(loadingFinished);
+ }
+
+ public void maybeThrowError(int sampleQueueIndex) throws IOException {
+ maybeThrowError();
+ sampleQueues[sampleQueueIndex].maybeThrowError();
+ }
+
+ public void maybeThrowError() throws IOException {
+ loader.maybeThrowError();
+ chunkSource.maybeThrowError();
+ }
+
+ public int readData(int sampleQueueIndex, FormatHolder formatHolder, DecoderInputBuffer buffer,
+ boolean requireFormat) {
+ if (isPendingReset()) {
+ return C.RESULT_NOTHING_READ;
+ }
+
+ // TODO: Split into discard (in discardBuffer) and format change (here and in skipData) steps.
+ if (!mediaChunks.isEmpty()) {
+ int discardToMediaChunkIndex = 0;
+ while (discardToMediaChunkIndex < mediaChunks.size() - 1
+ && finishedReadingChunk(mediaChunks.get(discardToMediaChunkIndex))) {
+ discardToMediaChunkIndex++;
+ }
+ Util.removeRange(mediaChunks, 0, discardToMediaChunkIndex);
+ HlsMediaChunk currentChunk = mediaChunks.get(0);
+ Format trackFormat = currentChunk.trackFormat;
+ if (!trackFormat.equals(downstreamTrackFormat)) {
+ eventDispatcher.downstreamFormatChanged(trackType, trackFormat,
+ currentChunk.trackSelectionReason, currentChunk.trackSelectionData,
+ currentChunk.startTimeUs);
+ }
+ downstreamTrackFormat = trackFormat;
+ }
+
+ int result =
+ sampleQueues[sampleQueueIndex].read(
+ formatHolder, buffer, requireFormat, loadingFinished, lastSeekPositionUs);
+ if (result == C.RESULT_FORMAT_READ) {
+ Format format = Assertions.checkNotNull(formatHolder.format);
+ if (sampleQueueIndex == primarySampleQueueIndex) {
+ // Fill in primary sample format with information from the track format.
+ int chunkUid = sampleQueues[sampleQueueIndex].peekSourceId();
+ int chunkIndex = 0;
+ while (chunkIndex < mediaChunks.size() && mediaChunks.get(chunkIndex).uid != chunkUid) {
+ chunkIndex++;
+ }
+ Format trackFormat =
+ chunkIndex < mediaChunks.size()
+ ? mediaChunks.get(chunkIndex).trackFormat
+ : Assertions.checkNotNull(upstreamTrackFormat);
+ format = format.copyWithManifestFormatInfo(trackFormat);
+ }
+ formatHolder.format = format;
+ }
+ return result;
+ }
+
+ public int skipData(int sampleQueueIndex, long positionUs) {
+ if (isPendingReset()) {
+ return 0;
+ }
+
+ SampleQueue sampleQueue = sampleQueues[sampleQueueIndex];
+ if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) {
+ return sampleQueue.advanceToEnd();
+ } else {
+ return sampleQueue.advanceTo(positionUs);
+ }
+ }
+
+ // SequenceableLoader implementation
+
+ @Override
+ public long getBufferedPositionUs() {
+ if (loadingFinished) {
+ return C.TIME_END_OF_SOURCE;
+ } else if (isPendingReset()) {
+ return pendingResetPositionUs;
+ } else {
+ long bufferedPositionUs = lastSeekPositionUs;
+ HlsMediaChunk lastMediaChunk = getLastMediaChunk();
+ HlsMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk
+ : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null;
+ if (lastCompletedMediaChunk != null) {
+ bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs);
+ }
+ if (sampleQueuesBuilt) {
+ for (SampleQueue sampleQueue : sampleQueues) {
+ bufferedPositionUs =
+ Math.max(bufferedPositionUs, sampleQueue.getLargestQueuedTimestampUs());
+ }
+ }
+ return bufferedPositionUs;
+ }
+ }
+
+ @Override
+ public long getNextLoadPositionUs() {
+ if (isPendingReset()) {
+ return pendingResetPositionUs;
+ } else {
+ return loadingFinished ? C.TIME_END_OF_SOURCE : getLastMediaChunk().endTimeUs;
+ }
+ }
+
+ @Override
+ public boolean continueLoading(long positionUs) {
+ if (loadingFinished || loader.isLoading() || loader.hasFatalError()) {
+ return false;
+ }
+
+ List<HlsMediaChunk> chunkQueue;
+ long loadPositionUs;
+ if (isPendingReset()) {
+ chunkQueue = Collections.emptyList();
+ loadPositionUs = pendingResetPositionUs;
+ } else {
+ chunkQueue = readOnlyMediaChunks;
+ HlsMediaChunk lastMediaChunk = getLastMediaChunk();
+ loadPositionUs =
+ lastMediaChunk.isLoadCompleted()
+ ? lastMediaChunk.endTimeUs
+ : Math.max(lastSeekPositionUs, lastMediaChunk.startTimeUs);
+ }
+ chunkSource.getNextChunk(
+ positionUs,
+ loadPositionUs,
+ chunkQueue,
+ /* allowEndOfStream= */ prepared || !chunkQueue.isEmpty(),
+ nextChunkHolder);
+ boolean endOfStream = nextChunkHolder.endOfStream;
+ Chunk loadable = nextChunkHolder.chunk;
+ Uri playlistUrlToLoad = nextChunkHolder.playlistUrl;
+ nextChunkHolder.clear();
+
+ if (endOfStream) {
+ pendingResetPositionUs = C.TIME_UNSET;
+ loadingFinished = true;
+ return true;
+ }
+
+ if (loadable == null) {
+ if (playlistUrlToLoad != null) {
+ callback.onPlaylistRefreshRequired(playlistUrlToLoad);
+ }
+ return false;
+ }
+
+ if (isMediaChunk(loadable)) {
+ pendingResetPositionUs = C.TIME_UNSET;
+ HlsMediaChunk mediaChunk = (HlsMediaChunk) loadable;
+ mediaChunk.init(this);
+ mediaChunks.add(mediaChunk);
+ upstreamTrackFormat = mediaChunk.trackFormat;
+ }
+ long elapsedRealtimeMs =
+ loader.startLoading(
+ loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type));
+ eventDispatcher.loadStarted(
+ loadable.dataSpec,
+ loadable.type,
+ trackType,
+ loadable.trackFormat,
+ loadable.trackSelectionReason,
+ loadable.trackSelectionData,
+ loadable.startTimeUs,
+ loadable.endTimeUs,
+ elapsedRealtimeMs);
+ return true;
+ }
+
+ @Override
+ public boolean isLoading() {
+ return loader.isLoading();
+ }
+
+ @Override
+ public void reevaluateBuffer(long positionUs) {
+ // Do nothing.
+ }
+
+ // Loader.Callback implementation.
+
+ @Override
+ public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) {
+ chunkSource.onChunkLoadCompleted(loadable);
+ eventDispatcher.loadCompleted(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ loadable.type,
+ trackType,
+ loadable.trackFormat,
+ loadable.trackSelectionReason,
+ loadable.trackSelectionData,
+ loadable.startTimeUs,
+ loadable.endTimeUs,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded());
+ if (!prepared) {
+ continueLoading(lastSeekPositionUs);
+ } else {
+ callback.onContinueLoadingRequested(this);
+ }
+ }
+
+ @Override
+ public void onLoadCanceled(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs,
+ boolean released) {
+ eventDispatcher.loadCanceled(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ loadable.type,
+ trackType,
+ loadable.trackFormat,
+ loadable.trackSelectionReason,
+ loadable.trackSelectionData,
+ loadable.startTimeUs,
+ loadable.endTimeUs,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded());
+ if (!released) {
+ resetSampleQueues();
+ if (enabledTrackGroupCount > 0) {
+ callback.onContinueLoadingRequested(this);
+ }
+ }
+ }
+
+ @Override
+ public LoadErrorAction onLoadError(
+ Chunk loadable,
+ long elapsedRealtimeMs,
+ long loadDurationMs,
+ IOException error,
+ int errorCount) {
+ long bytesLoaded = loadable.bytesLoaded();
+ boolean isMediaChunk = isMediaChunk(loadable);
+ boolean blacklistSucceeded = false;
+ LoadErrorAction loadErrorAction;
+
+ long blacklistDurationMs =
+ loadErrorHandlingPolicy.getBlacklistDurationMsFor(
+ loadable.type, loadDurationMs, error, errorCount);
+ if (blacklistDurationMs != C.TIME_UNSET) {
+ blacklistSucceeded = chunkSource.maybeBlacklistTrack(loadable, blacklistDurationMs);
+ }
+
+ if (blacklistSucceeded) {
+ if (isMediaChunk && bytesLoaded == 0) {
+ HlsMediaChunk removed = mediaChunks.remove(mediaChunks.size() - 1);
+ Assertions.checkState(removed == loadable);
+ if (mediaChunks.isEmpty()) {
+ pendingResetPositionUs = lastSeekPositionUs;
+ }
+ }
+ loadErrorAction = Loader.DONT_RETRY;
+ } else /* did not blacklist */ {
+ long retryDelayMs =
+ loadErrorHandlingPolicy.getRetryDelayMsFor(
+ loadable.type, loadDurationMs, error, errorCount);
+ loadErrorAction =
+ retryDelayMs != C.TIME_UNSET
+ ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs)
+ : Loader.DONT_RETRY_FATAL;
+ }
+
+ eventDispatcher.loadError(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ loadable.type,
+ trackType,
+ loadable.trackFormat,
+ loadable.trackSelectionReason,
+ loadable.trackSelectionData,
+ loadable.startTimeUs,
+ loadable.endTimeUs,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ bytesLoaded,
+ error,
+ /* wasCanceled= */ !loadErrorAction.isRetry());
+
+ if (blacklistSucceeded) {
+ if (!prepared) {
+ continueLoading(lastSeekPositionUs);
+ } else {
+ callback.onContinueLoadingRequested(this);
+ }
+ }
+ return loadErrorAction;
+ }
+
+ // Called by the consuming thread, but only when there is no loading thread.
+
+ /**
+ * Initializes the wrapper for loading a chunk.
+ *
+ * @param chunkUid The chunk's uid.
+ * @param shouldSpliceIn Whether the samples parsed from the chunk should be spliced into any
+ * samples already queued to the wrapper.
+ */
+ public void init(int chunkUid, boolean shouldSpliceIn) {
+ this.chunkUid = chunkUid;
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.sourceId(chunkUid);
+ }
+ if (shouldSpliceIn) {
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.splice();
+ }
+ }
+ }
+
+ // ExtractorOutput implementation. Called by the loading thread.
+
+ @Override
+ public TrackOutput track(int id, int type) {
+ @Nullable TrackOutput trackOutput = null;
+ if (MAPPABLE_TYPES.contains(type)) {
+ // Track types in MAPPABLE_TYPES are handled manually to ignore IDs.
+ trackOutput = getMappedTrackOutput(id, type);
+ } else /* non-mappable type track */ {
+ for (int i = 0; i < sampleQueues.length; i++) {
+ if (sampleQueueTrackIds[i] == id) {
+ trackOutput = sampleQueues[i];
+ break;
+ }
+ }
+ }
+
+ if (trackOutput == null) {
+ if (tracksEnded) {
+ return createDummyTrackOutput(id, type);
+ } else {
+ // The relevant SampleQueue hasn't been constructed yet - so construct it.
+ trackOutput = createSampleQueue(id, type);
+ }
+ }
+
+ if (type == C.TRACK_TYPE_METADATA) {
+ if (emsgUnwrappingTrackOutput == null) {
+ emsgUnwrappingTrackOutput = new EmsgUnwrappingTrackOutput(trackOutput, metadataType);
+ }
+ return emsgUnwrappingTrackOutput;
+ }
+ return trackOutput;
+ }
+
+ /**
+ * Returns the {@link TrackOutput} for the provided {@code type} and {@code id}, or null if none
+ * has been created yet.
+ *
+ * <p>If a {@link SampleQueue} for {@code type} has been created and is mapped, but it has a
+ * different ID, then return a {@link DummyTrackOutput} that does nothing.
+ *
+ * <p>If a {@link SampleQueue} for {@code type} has been created but is not mapped, then map it to
+ * this {@code id} and return it. This situation can happen after a call to {@link
+ * #onNewExtractor}.
+ *
+ * @param id The ID of the track.
+ * @param type The type of the track, must be one of {@link #MAPPABLE_TYPES}.
+ * @return The the mapped {@link TrackOutput}, or null if it's not been created yet.
+ */
+ @Nullable
+ private TrackOutput getMappedTrackOutput(int id, int type) {
+ Assertions.checkArgument(MAPPABLE_TYPES.contains(type));
+ int sampleQueueIndex = sampleQueueIndicesByType.get(type, C.INDEX_UNSET);
+ if (sampleQueueIndex == C.INDEX_UNSET) {
+ return null;
+ }
+
+ if (sampleQueueMappingDoneByType.add(type)) {
+ sampleQueueTrackIds[sampleQueueIndex] = id;
+ }
+ return sampleQueueTrackIds[sampleQueueIndex] == id
+ ? sampleQueues[sampleQueueIndex]
+ : createDummyTrackOutput(id, type);
+ }
+
+ private SampleQueue createSampleQueue(int id, int type) {
+ int trackCount = sampleQueues.length;
+
+ boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO;
+ FormatAdjustingSampleQueue trackOutput =
+ new FormatAdjustingSampleQueue(allocator, drmSessionManager, overridingDrmInitData);
+ if (isAudioVideo) {
+ trackOutput.setDrmInitData(drmInitData);
+ }
+ trackOutput.setSampleOffsetUs(sampleOffsetUs);
+ trackOutput.sourceId(chunkUid);
+ trackOutput.setUpstreamFormatChangeListener(this);
+ sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1);
+ sampleQueueTrackIds[trackCount] = id;
+ sampleQueues = Util.nullSafeArrayAppend(sampleQueues, trackOutput);
+ sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1);
+ sampleQueueIsAudioVideoFlags[trackCount] = isAudioVideo;
+ haveAudioVideoSampleQueues |= sampleQueueIsAudioVideoFlags[trackCount];
+ sampleQueueMappingDoneByType.add(type);
+ sampleQueueIndicesByType.append(type, trackCount);
+ if (getTrackTypeScore(type) > getTrackTypeScore(primarySampleQueueType)) {
+ primarySampleQueueIndex = trackCount;
+ primarySampleQueueType = type;
+ }
+ sampleQueuesEnabledStates = Arrays.copyOf(sampleQueuesEnabledStates, trackCount + 1);
+ return trackOutput;
+ }
+
+ @Override
+ public void endTracks() {
+ tracksEnded = true;
+ handler.post(onTracksEndedRunnable);
+ }
+
+ @Override
+ public void seekMap(SeekMap seekMap) {
+ // Do nothing.
+ }
+
+ // UpstreamFormatChangedListener implementation. Called by the loading thread.
+
+ @Override
+ public void onUpstreamFormatChanged(Format format) {
+ handler.post(maybeFinishPrepareRunnable);
+ }
+
+ // Called by the loading thread.
+
+ /** Called when an {@link HlsMediaChunk} starts extracting media with a new {@link Extractor}. */
+ public void onNewExtractor() {
+ sampleQueueMappingDoneByType.clear();
+ }
+
+ /**
+ * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that
+ * are subsequently loaded by this wrapper.
+ *
+ * @param sampleOffsetUs The timestamp offset in microseconds.
+ */
+ public void setSampleOffsetUs(long sampleOffsetUs) {
+ if (this.sampleOffsetUs != sampleOffsetUs) {
+ this.sampleOffsetUs = sampleOffsetUs;
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.setSampleOffsetUs(sampleOffsetUs);
+ }
+ }
+ }
+
+ /**
+ * Sets default {@link DrmInitData} for samples that are subsequently loaded by this wrapper.
+ *
+ * <p>This method should be called prior to loading each {@link HlsMediaChunk}. The {@link
+ * DrmInitData} passed should be that of an EXT-X-KEY tag that applies to the chunk, or {@code
+ * null} otherwise.
+ *
+ * <p>The final {@link DrmInitData} for subsequently queued samples is determined as followed:
+ *
+ * <ol>
+ * <li>It is initially set to {@code drmInitData}, unless {@code drmInitData} is null in which
+ * case it's set to {@link Format#drmInitData} of the upstream {@link Format}.
+ * <li>If the initial {@link DrmInitData} is non-null and {@link #overridingDrmInitData}
+ * contains an entry whose key matches the {@link DrmInitData#schemeType}, then the sample's
+ * {@link DrmInitData} is overridden to be this entry's value.
+ * </ol>
+ *
+ * <p>
+ *
+ * @param drmInitData The default {@link DrmInitData} for samples that are subsequently queued. If
+ * non-null then it takes precedence over {@link Format#drmInitData} of the upstream {@link
+ * Format}, but will still be overridden by a matching override in {@link
+ * #overridingDrmInitData}.
+ */
+ public void setDrmInitData(@Nullable DrmInitData drmInitData) {
+ if (!Util.areEqual(this.drmInitData, drmInitData)) {
+ this.drmInitData = drmInitData;
+ for (int i = 0; i < sampleQueues.length; i++) {
+ if (sampleQueueIsAudioVideoFlags[i]) {
+ sampleQueues[i].setDrmInitData(drmInitData);
+ }
+ }
+ }
+ }
+
+ // Internal methods.
+
+ private void updateSampleStreams(@NullableType SampleStream[] streams) {
+ hlsSampleStreams.clear();
+ for (SampleStream stream : streams) {
+ if (stream != null) {
+ hlsSampleStreams.add((HlsSampleStream) stream);
+ }
+ }
+ }
+
+ private boolean finishedReadingChunk(HlsMediaChunk chunk) {
+ int chunkUid = chunk.uid;
+ int sampleQueueCount = sampleQueues.length;
+ for (int i = 0; i < sampleQueueCount; i++) {
+ if (sampleQueuesEnabledStates[i] && sampleQueues[i].peekSourceId() == chunkUid) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void resetSampleQueues() {
+ for (SampleQueue sampleQueue : sampleQueues) {
+ sampleQueue.reset(pendingResetUpstreamFormats);
+ }
+ pendingResetUpstreamFormats = false;
+ }
+
+ private void onTracksEnded() {
+ sampleQueuesBuilt = true;
+ maybeFinishPrepare();
+ }
+
+ private void maybeFinishPrepare() {
+ if (released || trackGroupToSampleQueueIndex != null || !sampleQueuesBuilt) {
+ return;
+ }
+ for (SampleQueue sampleQueue : sampleQueues) {
+ if (sampleQueue.getUpstreamFormat() == null) {
+ return;
+ }
+ }
+ if (trackGroups != null) {
+ // The track groups were created with master playlist information. They only need to be mapped
+ // to a sample queue.
+ mapSampleQueuesToMatchTrackGroups();
+ } else {
+ // Tracks are created using media segment information.
+ buildTracksFromSampleStreams();
+ setIsPrepared();
+ callback.onPrepared();
+ }
+ }
+
+ @RequiresNonNull("trackGroups")
+ @EnsuresNonNull("trackGroupToSampleQueueIndex")
+ private void mapSampleQueuesToMatchTrackGroups() {
+ int trackGroupCount = trackGroups.length;
+ trackGroupToSampleQueueIndex = new int[trackGroupCount];
+ Arrays.fill(trackGroupToSampleQueueIndex, C.INDEX_UNSET);
+ for (int i = 0; i < trackGroupCount; i++) {
+ for (int queueIndex = 0; queueIndex < sampleQueues.length; queueIndex++) {
+ SampleQueue sampleQueue = sampleQueues[queueIndex];
+ if (formatsMatch(sampleQueue.getUpstreamFormat(), trackGroups.get(i).getFormat(0))) {
+ trackGroupToSampleQueueIndex[i] = queueIndex;
+ break;
+ }
+ }
+ }
+ for (HlsSampleStream sampleStream : hlsSampleStreams) {
+ sampleStream.bindSampleQueue();
+ }
+ }
+
+ /**
+ * Builds tracks that are exposed by this {@link HlsSampleStreamWrapper} instance, as well as
+ * internal data-structures required for operation.
+ *
+ * <p>Tracks in HLS are complicated. A HLS master playlist contains a number of "variants". Each
+ * variant stream typically contains muxed video, audio and (possibly) additional audio, metadata
+ * and caption tracks. We wish to allow the user to select between an adaptive track that spans
+ * all variants, as well as each individual variant. If multiple audio tracks are present within
+ * each variant then we wish to allow the user to select between those also.
+ *
+ * <p>To do this, tracks are constructed as follows. The {@link HlsChunkSource} exposes (N+1)
+ * tracks, where N is the number of variants defined in the HLS master playlist. These consist of
+ * one adaptive track defined to span all variants and a track for each individual variant. The
+ * adaptive track is initially selected. The extractor is then prepared to discover the tracks
+ * inside of each variant stream. The two sets of tracks are then combined by this method to
+ * create a third set, which is the set exposed by this {@link HlsSampleStreamWrapper}:
+ *
+ * <ul>
+ * <li>The extractor tracks are inspected to infer a "primary" track type. If a video track is
+ * present then it is always the primary type. If not, audio is the primary type if present.
+ * Else text is the primary type if present. Else there is no primary type.
+ * <li>If there is exactly one extractor track of the primary type, it's expanded into (N+1)
+ * exposed tracks, all of which correspond to the primary extractor track and each of which
+ * corresponds to a different chunk source track. Selecting one of these tracks has the
+ * effect of switching the selected track on the chunk source.
+ * <li>All other extractor tracks are exposed directly. Selecting one of these tracks has the
+ * effect of selecting an extractor track, leaving the selected track on the chunk source
+ * unchanged.
+ * </ul>
+ */
+ @EnsuresNonNull({"trackGroups", "optionalTrackGroups", "trackGroupToSampleQueueIndex"})
+ private void buildTracksFromSampleStreams() {
+ // Iterate through the extractor tracks to discover the "primary" track type, and the index
+ // of the single track of this type.
+ int primaryExtractorTrackType = C.TRACK_TYPE_NONE;
+ int primaryExtractorTrackIndex = C.INDEX_UNSET;
+ int extractorTrackCount = sampleQueues.length;
+ for (int i = 0; i < extractorTrackCount; i++) {
+ String sampleMimeType = sampleQueues[i].getUpstreamFormat().sampleMimeType;
+ int trackType;
+ if (MimeTypes.isVideo(sampleMimeType)) {
+ trackType = C.TRACK_TYPE_VIDEO;
+ } else if (MimeTypes.isAudio(sampleMimeType)) {
+ trackType = C.TRACK_TYPE_AUDIO;
+ } else if (MimeTypes.isText(sampleMimeType)) {
+ trackType = C.TRACK_TYPE_TEXT;
+ } else {
+ trackType = C.TRACK_TYPE_NONE;
+ }
+ if (getTrackTypeScore(trackType) > getTrackTypeScore(primaryExtractorTrackType)) {
+ primaryExtractorTrackType = trackType;
+ primaryExtractorTrackIndex = i;
+ } else if (trackType == primaryExtractorTrackType
+ && primaryExtractorTrackIndex != C.INDEX_UNSET) {
+ // We have multiple tracks of the primary type. We only want an index if there only exists a
+ // single track of the primary type, so unset the index again.
+ primaryExtractorTrackIndex = C.INDEX_UNSET;
+ }
+ }
+
+ TrackGroup chunkSourceTrackGroup = chunkSource.getTrackGroup();
+ int chunkSourceTrackCount = chunkSourceTrackGroup.length;
+
+ // Instantiate the necessary internal data-structures.
+ primaryTrackGroupIndex = C.INDEX_UNSET;
+ trackGroupToSampleQueueIndex = new int[extractorTrackCount];
+ for (int i = 0; i < extractorTrackCount; i++) {
+ trackGroupToSampleQueueIndex[i] = i;
+ }
+
+ // Construct the set of exposed track groups.
+ TrackGroup[] trackGroups = new TrackGroup[extractorTrackCount];
+ for (int i = 0; i < extractorTrackCount; i++) {
+ Format sampleFormat = sampleQueues[i].getUpstreamFormat();
+ if (i == primaryExtractorTrackIndex) {
+ Format[] formats = new Format[chunkSourceTrackCount];
+ if (chunkSourceTrackCount == 1) {
+ formats[0] = sampleFormat.copyWithManifestFormatInfo(chunkSourceTrackGroup.getFormat(0));
+ } else {
+ for (int j = 0; j < chunkSourceTrackCount; j++) {
+ formats[j] = deriveFormat(chunkSourceTrackGroup.getFormat(j), sampleFormat, true);
+ }
+ }
+ trackGroups[i] = new TrackGroup(formats);
+ primaryTrackGroupIndex = i;
+ } else {
+ Format trackFormat =
+ primaryExtractorTrackType == C.TRACK_TYPE_VIDEO
+ && MimeTypes.isAudio(sampleFormat.sampleMimeType)
+ ? muxedAudioFormat
+ : null;
+ trackGroups[i] = new TrackGroup(deriveFormat(trackFormat, sampleFormat, false));
+ }
+ }
+ this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups);
+ Assertions.checkState(optionalTrackGroups == null);
+ optionalTrackGroups = Collections.emptySet();
+ }
+
+ private TrackGroupArray createTrackGroupArrayWithDrmInfo(TrackGroup[] trackGroups) {
+ for (int i = 0; i < trackGroups.length; i++) {
+ TrackGroup trackGroup = trackGroups[i];
+ Format[] exposedFormats = new Format[trackGroup.length];
+ for (int j = 0; j < trackGroup.length; j++) {
+ Format format = trackGroup.getFormat(j);
+ if (format.drmInitData != null) {
+ format =
+ format.copyWithExoMediaCryptoType(
+ drmSessionManager.getExoMediaCryptoType(format.drmInitData));
+ }
+ exposedFormats[j] = format;
+ }
+ trackGroups[i] = new TrackGroup(exposedFormats);
+ }
+ return new TrackGroupArray(trackGroups);
+ }
+
+ private HlsMediaChunk getLastMediaChunk() {
+ return mediaChunks.get(mediaChunks.size() - 1);
+ }
+
+ private boolean isPendingReset() {
+ return pendingResetPositionUs != C.TIME_UNSET;
+ }
+
+ /**
+ * Attempts to seek to the specified position within the sample queues.
+ *
+ * @param positionUs The seek position in microseconds.
+ * @return Whether the in-buffer seek was successful.
+ */
+ private boolean seekInsideBufferUs(long positionUs) {
+ int sampleQueueCount = sampleQueues.length;
+ for (int i = 0; i < sampleQueueCount; i++) {
+ SampleQueue sampleQueue = sampleQueues[i];
+ boolean seekInsideQueue = sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false);
+ // If we have AV tracks then an in-queue seek is successful if the seek into every AV queue
+ // is successful. We ignore whether seeks within non-AV queues are successful in this case, as
+ // they may be sparse or poorly interleaved. If we only have non-AV tracks then a seek is
+ // successful only if the seek into every queue succeeds.
+ if (!seekInsideQueue && (sampleQueueIsAudioVideoFlags[i] || !haveAudioVideoSampleQueues)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @RequiresNonNull({"trackGroups", "optionalTrackGroups"})
+ private void setIsPrepared() {
+ prepared = true;
+ }
+
+ @EnsuresNonNull({"trackGroups", "optionalTrackGroups"})
+ private void assertIsPrepared() {
+ Assertions.checkState(prepared);
+ Assertions.checkNotNull(trackGroups);
+ Assertions.checkNotNull(optionalTrackGroups);
+ }
+
+ /**
+ * Scores a track type. Where multiple tracks are muxed into a container, the track with the
+ * highest score is the primary track.
+ *
+ * @param trackType The track type.
+ * @return The score.
+ */
+ private static int getTrackTypeScore(int trackType) {
+ switch (trackType) {
+ case C.TRACK_TYPE_VIDEO:
+ return 3;
+ case C.TRACK_TYPE_AUDIO:
+ return 2;
+ case C.TRACK_TYPE_TEXT:
+ return 1;
+ default:
+ return 0;
+ }
+ }
+
+ /**
+ * Derives a track sample format from the corresponding format in the master playlist, and a
+ * sample format that may have been obtained from a chunk belonging to a different track.
+ *
+ * @param playlistFormat The format information obtained from the master playlist.
+ * @param sampleFormat The format information obtained from the samples.
+ * @param propagateBitrate Whether the bitrate from the playlist format should be included in the
+ * derived format.
+ * @return The derived track format.
+ */
+ private static Format deriveFormat(
+ @Nullable Format playlistFormat, Format sampleFormat, boolean propagateBitrate) {
+ if (playlistFormat == null) {
+ return sampleFormat;
+ }
+ int bitrate = propagateBitrate ? playlistFormat.bitrate : Format.NO_VALUE;
+ int channelCount =
+ playlistFormat.channelCount != Format.NO_VALUE
+ ? playlistFormat.channelCount
+ : sampleFormat.channelCount;
+ int sampleTrackType = MimeTypes.getTrackType(sampleFormat.sampleMimeType);
+ String codecs = Util.getCodecsOfType(playlistFormat.codecs, sampleTrackType);
+ String mimeType = MimeTypes.getMediaMimeType(codecs);
+ if (mimeType == null) {
+ mimeType = sampleFormat.sampleMimeType;
+ }
+ return sampleFormat.copyWithContainerInfo(
+ playlistFormat.id,
+ playlistFormat.label,
+ mimeType,
+ codecs,
+ playlistFormat.metadata,
+ bitrate,
+ playlistFormat.width,
+ playlistFormat.height,
+ channelCount,
+ playlistFormat.selectionFlags,
+ playlistFormat.language);
+ }
+
+ private static boolean isMediaChunk(Chunk chunk) {
+ return chunk instanceof HlsMediaChunk;
+ }
+
+ private static boolean formatsMatch(Format manifestFormat, Format sampleFormat) {
+ String manifestFormatMimeType = manifestFormat.sampleMimeType;
+ String sampleFormatMimeType = sampleFormat.sampleMimeType;
+ int manifestFormatTrackType = MimeTypes.getTrackType(manifestFormatMimeType);
+ if (manifestFormatTrackType != C.TRACK_TYPE_TEXT) {
+ return manifestFormatTrackType == MimeTypes.getTrackType(sampleFormatMimeType);
+ } else if (!Util.areEqual(manifestFormatMimeType, sampleFormatMimeType)) {
+ return false;
+ }
+ if (MimeTypes.APPLICATION_CEA608.equals(manifestFormatMimeType)
+ || MimeTypes.APPLICATION_CEA708.equals(manifestFormatMimeType)) {
+ return manifestFormat.accessibilityChannel == sampleFormat.accessibilityChannel;
+ }
+ return true;
+ }
+
+ private static DummyTrackOutput createDummyTrackOutput(int id, int type) {
+ Log.w(TAG, "Unmapped track with id " + id + " of type " + type);
+ return new DummyTrackOutput();
+ }
+
+ private static final class FormatAdjustingSampleQueue extends SampleQueue {
+
+ private final Map<String, DrmInitData> overridingDrmInitData;
+ @Nullable private DrmInitData drmInitData;
+
+ public FormatAdjustingSampleQueue(
+ Allocator allocator,
+ DrmSessionManager<?> drmSessionManager,
+ Map<String, DrmInitData> overridingDrmInitData) {
+ super(allocator, drmSessionManager);
+ this.overridingDrmInitData = overridingDrmInitData;
+ }
+
+ public void setDrmInitData(@Nullable DrmInitData drmInitData) {
+ this.drmInitData = drmInitData;
+ invalidateUpstreamFormatAdjustment();
+ }
+
+ @Override
+ public Format getAdjustedUpstreamFormat(Format format) {
+ @Nullable
+ DrmInitData drmInitData = this.drmInitData != null ? this.drmInitData : format.drmInitData;
+ if (drmInitData != null) {
+ @Nullable
+ DrmInitData overridingDrmInitData = this.overridingDrmInitData.get(drmInitData.schemeType);
+ if (overridingDrmInitData != null) {
+ drmInitData = overridingDrmInitData;
+ }
+ }
+ return super.getAdjustedUpstreamFormat(
+ format.copyWithAdjustments(drmInitData, getAdjustedMetadata(format.metadata)));
+ }
+
+ /**
+ * Strips the private timestamp frame from metadata, if present. See:
+ * https://github.com/google/ExoPlayer/issues/5063
+ */
+ @Nullable
+ private Metadata getAdjustedMetadata(@Nullable Metadata metadata) {
+ if (metadata == null) {
+ return null;
+ }
+ int length = metadata.length();
+ int transportStreamTimestampMetadataIndex = C.INDEX_UNSET;
+ for (int i = 0; i < length; i++) {
+ Metadata.Entry metadataEntry = metadata.get(i);
+ if (metadataEntry instanceof PrivFrame) {
+ PrivFrame privFrame = (PrivFrame) metadataEntry;
+ if (HlsMediaChunk.PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) {
+ transportStreamTimestampMetadataIndex = i;
+ break;
+ }
+ }
+ }
+ if (transportStreamTimestampMetadataIndex == C.INDEX_UNSET) {
+ return metadata;
+ }
+ if (length == 1) {
+ return null;
+ }
+ Metadata.Entry[] newMetadataEntries = new Metadata.Entry[length - 1];
+ for (int i = 0; i < length; i++) {
+ if (i != transportStreamTimestampMetadataIndex) {
+ int newIndex = i < transportStreamTimestampMetadataIndex ? i : i - 1;
+ newMetadataEntries[newIndex] = metadata.get(i);
+ }
+ }
+ return new Metadata(newMetadataEntries);
+ }
+ }
+
+ private static class EmsgUnwrappingTrackOutput implements TrackOutput {
+
+ private static final String TAG = "EmsgUnwrappingTrackOutput";
+
+ // TODO(ibaker): Create a Formats util class with common constants like this.
+ private static final Format ID3_FORMAT =
+ Format.createSampleFormat(
+ /* id= */ null, MimeTypes.APPLICATION_ID3, Format.OFFSET_SAMPLE_RELATIVE);
+ private static final Format EMSG_FORMAT =
+ Format.createSampleFormat(
+ /* id= */ null, MimeTypes.APPLICATION_EMSG, Format.OFFSET_SAMPLE_RELATIVE);
+
+ private final EventMessageDecoder emsgDecoder;
+ private final TrackOutput delegate;
+ private final Format delegateFormat;
+ @MonotonicNonNull private Format format;
+
+ private byte[] buffer;
+ private int bufferPosition;
+
+ public EmsgUnwrappingTrackOutput(
+ TrackOutput delegate, @HlsMediaSource.MetadataType int metadataType) {
+ this.emsgDecoder = new EventMessageDecoder();
+ this.delegate = delegate;
+ switch (metadataType) {
+ case HlsMediaSource.METADATA_TYPE_ID3:
+ delegateFormat = ID3_FORMAT;
+ break;
+ case HlsMediaSource.METADATA_TYPE_EMSG:
+ delegateFormat = EMSG_FORMAT;
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown metadataType: " + metadataType);
+ }
+
+ this.buffer = new byte[0];
+ this.bufferPosition = 0;
+ }
+
+ @Override
+ public void format(Format format) {
+ this.format = format;
+ delegate.format(delegateFormat);
+ }
+
+ @Override
+ public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput)
+ throws IOException, InterruptedException {
+ ensureBufferCapacity(bufferPosition + length);
+ int numBytesRead = input.read(buffer, bufferPosition, length);
+ if (numBytesRead == C.RESULT_END_OF_INPUT) {
+ if (allowEndOfInput) {
+ return C.RESULT_END_OF_INPUT;
+ } else {
+ throw new EOFException();
+ }
+ }
+ bufferPosition += numBytesRead;
+ return numBytesRead;
+ }
+
+ @Override
+ public void sampleData(ParsableByteArray buffer, int length) {
+ ensureBufferCapacity(bufferPosition + length);
+ buffer.readBytes(this.buffer, bufferPosition, length);
+ bufferPosition += length;
+ }
+
+ @Override
+ public void sampleMetadata(
+ long timeUs,
+ @C.BufferFlags int flags,
+ int size,
+ int offset,
+ @Nullable CryptoData cryptoData) {
+ Assertions.checkNotNull(format);
+ ParsableByteArray sample = getSampleAndTrimBuffer(size, offset);
+ ParsableByteArray sampleForDelegate;
+ if (Util.areEqual(format.sampleMimeType, delegateFormat.sampleMimeType)) {
+ // Incoming format matches delegate track's format, so pass straight through.
+ sampleForDelegate = sample;
+ } else if (MimeTypes.APPLICATION_EMSG.equals(format.sampleMimeType)) {
+ // Incoming sample is EMSG, and delegate track is not expecting EMSG, so try unwrapping.
+ EventMessage emsg = emsgDecoder.decode(sample);
+ if (!emsgContainsExpectedWrappedFormat(emsg)) {
+ Log.w(
+ TAG,
+ String.format(
+ "Ignoring EMSG. Expected it to contain wrapped %s but actual wrapped format: %s",
+ delegateFormat.sampleMimeType, emsg.getWrappedMetadataFormat()));
+ return;
+ }
+ sampleForDelegate =
+ new ParsableByteArray(Assertions.checkNotNull(emsg.getWrappedMetadataBytes()));
+ } else {
+ Log.w(TAG, "Ignoring sample for unsupported format: " + format.sampleMimeType);
+ return;
+ }
+
+ int sampleSize = sampleForDelegate.bytesLeft();
+
+ delegate.sampleData(sampleForDelegate, sampleSize);
+ delegate.sampleMetadata(timeUs, flags, sampleSize, offset, cryptoData);
+ }
+
+ private boolean emsgContainsExpectedWrappedFormat(EventMessage emsg) {
+ @Nullable Format wrappedMetadataFormat = emsg.getWrappedMetadataFormat();
+ return wrappedMetadataFormat != null
+ && Util.areEqual(delegateFormat.sampleMimeType, wrappedMetadataFormat.sampleMimeType);
+ }
+
+ private void ensureBufferCapacity(int requiredLength) {
+ if (buffer.length < requiredLength) {
+ buffer = Arrays.copyOf(buffer, requiredLength + requiredLength / 2);
+ }
+ }
+
+ /**
+ * Removes a complete sample from the {@link #buffer} field & reshuffles the tail data skipped
+ * by {@code offset} to the head of the array.
+ *
+ * @param size see {@code size} param of {@link #sampleMetadata}.
+ * @param offset see {@code offset} param of {@link #sampleMetadata}.
+ * @return A {@link ParsableByteArray} containing the sample removed from {@link #buffer}.
+ */
+ private ParsableByteArray getSampleAndTrimBuffer(int size, int offset) {
+ int sampleEnd = bufferPosition - offset;
+ int sampleStart = sampleEnd - size;
+
+ byte[] sampleBytes = Arrays.copyOfRange(buffer, sampleStart, sampleEnd);
+ ParsableByteArray sample = new ParsableByteArray(sampleBytes);
+
+ System.arraycopy(buffer, sampleEnd, buffer, 0, offset);
+ bufferPosition = offset;
+ return sample;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java
new file mode 100644
index 0000000000..681fe57240
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Holds metadata associated to an HLS media track. */
+public final class HlsTrackMetadataEntry implements Metadata.Entry {
+
+ /** Holds attributes defined in an EXT-X-STREAM-INF tag. */
+ public static final class VariantInfo implements Parcelable {
+
+ /** The bitrate as declared by the EXT-X-STREAM-INF tag. */
+ public final long bitrate;
+
+ /**
+ * The VIDEO value as defined in the EXT-X-STREAM-INF tag, or null if the VIDEO attribute is not
+ * present.
+ */
+ @Nullable public final String videoGroupId;
+
+ /**
+ * The AUDIO value as defined in the EXT-X-STREAM-INF tag, or null if the AUDIO attribute is not
+ * present.
+ */
+ @Nullable public final String audioGroupId;
+
+ /**
+ * The SUBTITLES value as defined in the EXT-X-STREAM-INF tag, or null if the SUBTITLES
+ * attribute is not present.
+ */
+ @Nullable public final String subtitleGroupId;
+
+ /**
+ * The CLOSED-CAPTIONS value as defined in the EXT-X-STREAM-INF tag, or null if the
+ * CLOSED-CAPTIONS attribute is not present.
+ */
+ @Nullable public final String captionGroupId;
+
+ /**
+ * Creates an instance.
+ *
+ * @param bitrate See {@link #bitrate}.
+ * @param videoGroupId See {@link #videoGroupId}.
+ * @param audioGroupId See {@link #audioGroupId}.
+ * @param subtitleGroupId See {@link #subtitleGroupId}.
+ * @param captionGroupId See {@link #captionGroupId}.
+ */
+ public VariantInfo(
+ long bitrate,
+ @Nullable String videoGroupId,
+ @Nullable String audioGroupId,
+ @Nullable String subtitleGroupId,
+ @Nullable String captionGroupId) {
+ this.bitrate = bitrate;
+ this.videoGroupId = videoGroupId;
+ this.audioGroupId = audioGroupId;
+ this.subtitleGroupId = subtitleGroupId;
+ this.captionGroupId = captionGroupId;
+ }
+
+ /* package */ VariantInfo(Parcel in) {
+ bitrate = in.readLong();
+ videoGroupId = in.readString();
+ audioGroupId = in.readString();
+ subtitleGroupId = in.readString();
+ captionGroupId = in.readString();
+ }
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (other == null || getClass() != other.getClass()) {
+ return false;
+ }
+ VariantInfo that = (VariantInfo) other;
+ return bitrate == that.bitrate
+ && TextUtils.equals(videoGroupId, that.videoGroupId)
+ && TextUtils.equals(audioGroupId, that.audioGroupId)
+ && TextUtils.equals(subtitleGroupId, that.subtitleGroupId)
+ && TextUtils.equals(captionGroupId, that.captionGroupId);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = (int) (bitrate ^ (bitrate >>> 32));
+ result = 31 * result + (videoGroupId != null ? videoGroupId.hashCode() : 0);
+ result = 31 * result + (audioGroupId != null ? audioGroupId.hashCode() : 0);
+ result = 31 * result + (subtitleGroupId != null ? subtitleGroupId.hashCode() : 0);
+ result = 31 * result + (captionGroupId != null ? captionGroupId.hashCode() : 0);
+ return result;
+ }
+
+ // Parcelable implementation.
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeLong(bitrate);
+ dest.writeString(videoGroupId);
+ dest.writeString(audioGroupId);
+ dest.writeString(subtitleGroupId);
+ dest.writeString(captionGroupId);
+ }
+
+ public static final Parcelable.Creator<VariantInfo> CREATOR =
+ new Parcelable.Creator<VariantInfo>() {
+ @Override
+ public VariantInfo createFromParcel(Parcel in) {
+ return new VariantInfo(in);
+ }
+
+ @Override
+ public VariantInfo[] newArray(int size) {
+ return new VariantInfo[size];
+ }
+ };
+ }
+
+ /**
+ * The GROUP-ID value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the
+ * track is not derived from an EXT-X-MEDIA TAG.
+ */
+ @Nullable public final String groupId;
+ /**
+ * The NAME value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the
+ * track is not derived from an EXT-X-MEDIA TAG.
+ */
+ @Nullable public final String name;
+ /**
+ * The EXT-X-STREAM-INF tags attributes associated with this track. This field is non-applicable
+ * (and therefore empty) if this track is derived from an EXT-X-MEDIA tag.
+ */
+ public final List<VariantInfo> variantInfos;
+
+ /**
+ * Creates an instance.
+ *
+ * @param groupId See {@link #groupId}.
+ * @param name See {@link #name}.
+ * @param variantInfos See {@link #variantInfos}.
+ */
+ public HlsTrackMetadataEntry(
+ @Nullable String groupId, @Nullable String name, List<VariantInfo> variantInfos) {
+ this.groupId = groupId;
+ this.name = name;
+ this.variantInfos = Collections.unmodifiableList(new ArrayList<>(variantInfos));
+ }
+
+ /* package */ HlsTrackMetadataEntry(Parcel in) {
+ groupId = in.readString();
+ name = in.readString();
+ int variantInfoSize = in.readInt();
+ ArrayList<VariantInfo> variantInfos = new ArrayList<>(variantInfoSize);
+ for (int i = 0; i < variantInfoSize; i++) {
+ variantInfos.add(in.readParcelable(VariantInfo.class.getClassLoader()));
+ }
+ this.variantInfos = Collections.unmodifiableList(variantInfos);
+ }
+
+ @Override
+ public String toString() {
+ return "HlsTrackMetadataEntry" + (groupId != null ? (" [" + groupId + ", " + name + "]") : "");
+ }
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (other == null || getClass() != other.getClass()) {
+ return false;
+ }
+
+ HlsTrackMetadataEntry that = (HlsTrackMetadataEntry) other;
+ return TextUtils.equals(groupId, that.groupId)
+ && TextUtils.equals(name, that.name)
+ && variantInfos.equals(that.variantInfos);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = groupId != null ? groupId.hashCode() : 0;
+ result = 31 * result + (name != null ? name.hashCode() : 0);
+ result = 31 * result + variantInfos.hashCode();
+ return result;
+ }
+
+ // Parcelable implementation.
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(groupId);
+ dest.writeString(name);
+ int variantInfosSize = variantInfos.size();
+ dest.writeInt(variantInfosSize);
+ for (int i = 0; i < variantInfosSize; i++) {
+ dest.writeParcelable(variantInfos.get(i), /* parcelableFlags= */ 0);
+ }
+ }
+
+ public static final Parcelable.Creator<HlsTrackMetadataEntry> CREATOR =
+ new Parcelable.Creator<HlsTrackMetadataEntry>() {
+ @Override
+ public HlsTrackMetadataEntry createFromParcel(Parcel in) {
+ return new HlsTrackMetadataEntry(in);
+ }
+
+ @Override
+ public HlsTrackMetadataEntry[] newArray(int size) {
+ return new HlsTrackMetadataEntry[size];
+ }
+ };
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java
new file mode 100644
index 0000000000..a67a92b4b7
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
+import java.io.IOException;
+
+/** Thrown when it is not possible to map a {@link TrackGroup} to a {@link SampleQueue}. */
+public final class SampleQueueMappingException extends IOException {
+
+ /** @param mimeType The mime type of the track group whose mapping failed. */
+ public SampleQueueMappingException(@Nullable String mimeType) {
+ super("Unable to bind a sample queue to TrackGroup with mime type " + mimeType + ".");
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java
new file mode 100644
index 0000000000..e2a652d05c
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.util.SparseArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster;
+
+/**
+ * Provides {@link TimestampAdjuster} instances for use during HLS playbacks.
+ */
+public final class TimestampAdjusterProvider {
+
+ // TODO: Prevent this array from growing indefinitely large by removing adjusters that are no
+ // longer required.
+ private final SparseArray<TimestampAdjuster> timestampAdjusters;
+
+ public TimestampAdjusterProvider() {
+ timestampAdjusters = new SparseArray<>();
+ }
+
+ /**
+ * Returns a {@link TimestampAdjuster} suitable for adjusting the pts timestamps contained in
+ * a chunk with a given discontinuity sequence.
+ *
+ * @param discontinuitySequence The chunk's discontinuity sequence.
+ * @return A {@link TimestampAdjuster}.
+ */
+ public TimestampAdjuster getAdjuster(int discontinuitySequence) {
+ TimestampAdjuster adjuster = timestampAdjusters.get(discontinuitySequence);
+ if (adjuster == null) {
+ adjuster = new TimestampAdjuster(TimestampAdjuster.DO_NOT_OFFSET);
+ timestampAdjusters.put(discontinuitySequence, adjuster);
+ }
+ return adjuster;
+ }
+
+ /**
+ * Resets the provider.
+ */
+ public void reset() {
+ timestampAdjusters.clear();
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java
new file mode 100644
index 0000000000..1d5e669a03
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.WebvttParserUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * A special purpose extractor for WebVTT content in HLS.
+ *
+ * <p>This extractor passes through non-empty WebVTT files untouched, however derives the correct
+ * sample timestamp for each by sniffing the X-TIMESTAMP-MAP header along with the start timestamp
+ * of the first cue header. Empty WebVTT files are not passed through, since it's not possible to
+ * derive a sample timestamp in this case.
+ */
+public final class WebvttExtractor implements Extractor {
+
+ private static final Pattern LOCAL_TIMESTAMP = Pattern.compile("LOCAL:([^,]+)");
+ private static final Pattern MEDIA_TIMESTAMP = Pattern.compile("MPEGTS:(-?\\d+)");
+ private static final int HEADER_MIN_LENGTH = 6 /* "WEBVTT" */;
+ private static final int HEADER_MAX_LENGTH = 3 /* optional Byte Order Mark */ + HEADER_MIN_LENGTH;
+
+ @Nullable private final String language;
+ private final TimestampAdjuster timestampAdjuster;
+ private final ParsableByteArray sampleDataWrapper;
+
+ private @MonotonicNonNull ExtractorOutput output;
+
+ private byte[] sampleData;
+ private int sampleSize;
+
+ public WebvttExtractor(@Nullable String language, TimestampAdjuster timestampAdjuster) {
+ this.language = language;
+ this.timestampAdjuster = timestampAdjuster;
+ this.sampleDataWrapper = new ParsableByteArray();
+ sampleData = new byte[1024];
+ }
+
+ // Extractor implementation.
+
+ @Override
+ public boolean sniff(ExtractorInput input) throws IOException, InterruptedException {
+ // Check whether there is a header without BOM.
+ input.peekFully(
+ sampleData, /* offset= */ 0, /* length= */ HEADER_MIN_LENGTH, /* allowEndOfInput= */ false);
+ sampleDataWrapper.reset(sampleData, HEADER_MIN_LENGTH);
+ if (WebvttParserUtil.isWebvttHeaderLine(sampleDataWrapper)) {
+ return true;
+ }
+ // The header did not match, try including the BOM.
+ input.peekFully(
+ sampleData,
+ /* offset= */ HEADER_MIN_LENGTH,
+ HEADER_MAX_LENGTH - HEADER_MIN_LENGTH,
+ /* allowEndOfInput= */ false);
+ sampleDataWrapper.reset(sampleData, HEADER_MAX_LENGTH);
+ return WebvttParserUtil.isWebvttHeaderLine(sampleDataWrapper);
+ }
+
+ @Override
+ public void init(ExtractorOutput output) {
+ this.output = output;
+ output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET));
+ }
+
+ @Override
+ public void seek(long position, long timeUs) {
+ // This extractor is only used for the HLS use case, which should not call this method.
+ throw new IllegalStateException();
+ }
+
+ @Override
+ public void release() {
+ // Do nothing
+ }
+
+ @Override
+ public int read(ExtractorInput input, PositionHolder seekPosition)
+ throws IOException, InterruptedException {
+ // output == null suggests init() hasn't been called
+ Assertions.checkNotNull(output);
+ int currentFileSize = (int) input.getLength();
+
+ // Increase the size of sampleData if necessary.
+ if (sampleSize == sampleData.length) {
+ sampleData = Arrays.copyOf(sampleData,
+ (currentFileSize != C.LENGTH_UNSET ? currentFileSize : sampleData.length) * 3 / 2);
+ }
+
+ // Consume to the input.
+ int bytesRead = input.read(sampleData, sampleSize, sampleData.length - sampleSize);
+ if (bytesRead != C.RESULT_END_OF_INPUT) {
+ sampleSize += bytesRead;
+ if (currentFileSize == C.LENGTH_UNSET || sampleSize != currentFileSize) {
+ return Extractor.RESULT_CONTINUE;
+ }
+ }
+
+ // We've reached the end of the input, which corresponds to the end of the current file.
+ processSample();
+ return Extractor.RESULT_END_OF_INPUT;
+ }
+
+ @RequiresNonNull("output")
+ private void processSample() throws ParserException {
+ ParsableByteArray webvttData = new ParsableByteArray(sampleData);
+
+ // Validate the first line of the header.
+ WebvttParserUtil.validateWebvttHeaderLine(webvttData);
+
+ // Defaults to use if the header doesn't contain an X-TIMESTAMP-MAP header.
+ long vttTimestampUs = 0;
+ long tsTimestampUs = 0;
+
+ // Parse the remainder of the header looking for X-TIMESTAMP-MAP.
+ for (String line = webvttData.readLine();
+ !TextUtils.isEmpty(line);
+ line = webvttData.readLine()) {
+ if (line.startsWith("X-TIMESTAMP-MAP")) {
+ Matcher localTimestampMatcher = LOCAL_TIMESTAMP.matcher(line);
+ if (!localTimestampMatcher.find()) {
+ throw new ParserException("X-TIMESTAMP-MAP doesn't contain local timestamp: " + line);
+ }
+ Matcher mediaTimestampMatcher = MEDIA_TIMESTAMP.matcher(line);
+ if (!mediaTimestampMatcher.find()) {
+ throw new ParserException("X-TIMESTAMP-MAP doesn't contain media timestamp: " + line);
+ }
+ vttTimestampUs = WebvttParserUtil.parseTimestampUs(localTimestampMatcher.group(1));
+ tsTimestampUs = TimestampAdjuster.ptsToUs(Long.parseLong(mediaTimestampMatcher.group(1)));
+ }
+ }
+
+ // Find the first cue header and parse the start time.
+ Matcher cueHeaderMatcher = WebvttParserUtil.findNextCueHeader(webvttData);
+ if (cueHeaderMatcher == null) {
+ // No cues found. Don't output a sample, but still output a corresponding track.
+ buildTrackOutput(0);
+ return;
+ }
+
+ long firstCueTimeUs = WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1));
+ long sampleTimeUs = timestampAdjuster.adjustTsTimestamp(
+ TimestampAdjuster.usToPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs));
+ long subsampleOffsetUs = sampleTimeUs - firstCueTimeUs;
+ // Output the track.
+ TrackOutput trackOutput = buildTrackOutput(subsampleOffsetUs);
+ // Output the sample.
+ sampleDataWrapper.reset(sampleData, sampleSize);
+ trackOutput.sampleData(sampleDataWrapper, sampleSize);
+ trackOutput.sampleMetadata(sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null);
+ }
+
+ @RequiresNonNull("output")
+ private TrackOutput buildTrackOutput(long subsampleOffsetUs) {
+ TrackOutput trackOutput = output.track(0, C.TRACK_TYPE_TEXT);
+ trackOutput.format(Format.createTextSampleFormat(null, MimeTypes.TEXT_VTT, null,
+ Format.NO_VALUE, 0, language, null, subsampleOffsetUs));
+ output.endTracks();
+ return trackOutput;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java
new file mode 100644
index 0000000000..636100a8a9
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.offline;
+
+import android.net.Uri;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.SegmentDownloader;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * A downloader for HLS streams.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
+ * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
+ * DownloaderConstructorHelper constructorHelper =
+ * new DownloaderConstructorHelper(cache, factory);
+ * // Create a downloader for the first variant in a master playlist.
+ * HlsDownloader hlsDownloader =
+ * new HlsDownloader(
+ * playlistUri,
+ * Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)),
+ * constructorHelper);
+ * // Perform the download.
+ * hlsDownloader.download(progressListener);
+ * // Access downloaded data using CacheDataSource
+ * CacheDataSource cacheDataSource =
+ * new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
+ * }</pre>
+ */
+public final class HlsDownloader extends SegmentDownloader<HlsPlaylist> {
+
+ /**
+ * @param playlistUri The {@link Uri} of the playlist to be downloaded.
+ * @param streamKeys Keys defining which renditions in the playlist should be selected for
+ * download. If empty, all renditions are downloaded.
+ * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+ */
+ public HlsDownloader(
+ Uri playlistUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) {
+ super(playlistUri, streamKeys, constructorHelper);
+ }
+
+ @Override
+ protected HlsPlaylist getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException {
+ return loadManifest(dataSource, dataSpec);
+ }
+
+ @Override
+ protected List<Segment> getSegments(
+ DataSource dataSource, HlsPlaylist playlist, boolean allowIncompleteList) throws IOException {
+ ArrayList<DataSpec> mediaPlaylistDataSpecs = new ArrayList<>();
+ if (playlist instanceof HlsMasterPlaylist) {
+ HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist;
+ addMediaPlaylistDataSpecs(masterPlaylist.mediaPlaylistUrls, mediaPlaylistDataSpecs);
+ } else {
+ mediaPlaylistDataSpecs.add(
+ SegmentDownloader.getCompressibleDataSpec(Uri.parse(playlist.baseUri)));
+ }
+
+ ArrayList<Segment> segments = new ArrayList<>();
+ HashSet<Uri> seenEncryptionKeyUris = new HashSet<>();
+ for (DataSpec mediaPlaylistDataSpec : mediaPlaylistDataSpecs) {
+ segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec));
+ HlsMediaPlaylist mediaPlaylist;
+ try {
+ mediaPlaylist = (HlsMediaPlaylist) loadManifest(dataSource, mediaPlaylistDataSpec);
+ } catch (IOException e) {
+ if (!allowIncompleteList) {
+ throw e;
+ }
+ // Generating an incomplete segment list is allowed. Advance to the next media playlist.
+ continue;
+ }
+ HlsMediaPlaylist.Segment lastInitSegment = null;
+ List<HlsMediaPlaylist.Segment> hlsSegments = mediaPlaylist.segments;
+ for (int i = 0; i < hlsSegments.size(); i++) {
+ HlsMediaPlaylist.Segment segment = hlsSegments.get(i);
+ HlsMediaPlaylist.Segment initSegment = segment.initializationSegment;
+ if (initSegment != null && initSegment != lastInitSegment) {
+ lastInitSegment = initSegment;
+ addSegment(mediaPlaylist, initSegment, seenEncryptionKeyUris, segments);
+ }
+ addSegment(mediaPlaylist, segment, seenEncryptionKeyUris, segments);
+ }
+ }
+ return segments;
+ }
+
+ private void addMediaPlaylistDataSpecs(List<Uri> mediaPlaylistUrls, List<DataSpec> out) {
+ for (int i = 0; i < mediaPlaylistUrls.size(); i++) {
+ out.add(SegmentDownloader.getCompressibleDataSpec(mediaPlaylistUrls.get(i)));
+ }
+ }
+
+ private static HlsPlaylist loadManifest(DataSource dataSource, DataSpec dataSpec)
+ throws IOException {
+ return ParsingLoadable.load(
+ dataSource, new HlsPlaylistParser(), dataSpec, C.DATA_TYPE_MANIFEST);
+ }
+
+ private void addSegment(
+ HlsMediaPlaylist mediaPlaylist,
+ HlsMediaPlaylist.Segment segment,
+ HashSet<Uri> seenEncryptionKeyUris,
+ ArrayList<Segment> out) {
+ String baseUri = mediaPlaylist.baseUri;
+ long startTimeUs = mediaPlaylist.startTimeUs + segment.relativeStartTimeUs;
+ if (segment.fullSegmentEncryptionKeyUri != null) {
+ Uri keyUri = UriUtil.resolveToUri(baseUri, segment.fullSegmentEncryptionKeyUri);
+ if (seenEncryptionKeyUris.add(keyUri)) {
+ out.add(new Segment(startTimeUs, SegmentDownloader.getCompressibleDataSpec(keyUri)));
+ }
+ }
+ Uri segmentUri = UriUtil.resolveToUri(baseUri, segment.url);
+ DataSpec dataSpec =
+ new DataSpec(segmentUri, segment.byterangeOffset, segment.byterangeLength, /* key= */ null);
+ out.add(new Segment(startTimeUs, dataSpec));
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java
new file mode 100644
index 0000000000..669bd44c89
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.offline;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java
new file mode 100644
index 0000000000..89882bb596
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java
new file mode 100644
index 0000000000..394a97a56a
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable;
+
+/** Default implementation for {@link HlsPlaylistParserFactory}. */
+public final class DefaultHlsPlaylistParserFactory implements HlsPlaylistParserFactory {
+
+ @Override
+ public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() {
+ return new HlsPlaylistParser();
+ }
+
+ @Override
+ public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(
+ HlsMasterPlaylist masterPlaylist) {
+ return new HlsPlaylistParser(masterPlaylist);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java
new file mode 100644
index 0000000000..b7f6a06975
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java
@@ -0,0 +1,678 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import android.net.Uri;
+import android.os.Handler;
+import android.os.SystemClock;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+/** Default implementation for {@link HlsPlaylistTracker}. */
+public final class DefaultHlsPlaylistTracker
+ implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable<HlsPlaylist>> {
+
+ /** Factory for {@link DefaultHlsPlaylistTracker} instances. */
+ public static final Factory FACTORY = DefaultHlsPlaylistTracker::new;
+
+ /**
+ * Default coefficient applied on the target duration of a playlist to determine the amount of
+ * time after which an unchanging playlist is considered stuck.
+ */
+ public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 3.5;
+
+ private final HlsDataSourceFactory dataSourceFactory;
+ private final HlsPlaylistParserFactory playlistParserFactory;
+ private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
+ private final HashMap<Uri, MediaPlaylistBundle> playlistBundles;
+ private final List<PlaylistEventListener> listeners;
+ private final double playlistStuckTargetDurationCoefficient;
+
+ @Nullable private ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser;
+ @Nullable private EventDispatcher eventDispatcher;
+ @Nullable private Loader initialPlaylistLoader;
+ @Nullable private Handler playlistRefreshHandler;
+ @Nullable private PrimaryPlaylistListener primaryPlaylistListener;
+ @Nullable private HlsMasterPlaylist masterPlaylist;
+ @Nullable private Uri primaryMediaPlaylistUrl;
+ @Nullable private HlsMediaPlaylist primaryMediaPlaylistSnapshot;
+ private boolean isLive;
+ private long initialStartTimeUs;
+
+ /**
+ * Creates an instance.
+ *
+ * @param dataSourceFactory A factory for {@link DataSource} instances.
+ * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}.
+ * @param playlistParserFactory An {@link HlsPlaylistParserFactory}.
+ */
+ public DefaultHlsPlaylistTracker(
+ HlsDataSourceFactory dataSourceFactory,
+ LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+ HlsPlaylistParserFactory playlistParserFactory) {
+ this(
+ dataSourceFactory,
+ loadErrorHandlingPolicy,
+ playlistParserFactory,
+ DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ * @param dataSourceFactory A factory for {@link DataSource} instances.
+ * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}.
+ * @param playlistParserFactory An {@link HlsPlaylistParserFactory}.
+ * @param playlistStuckTargetDurationCoefficient A coefficient to apply to the target duration of
+ * media playlists in order to determine that a non-changing playlist is stuck. Once a
+ * playlist is deemed stuck, a {@link PlaylistStuckException} is thrown via {@link
+ * #maybeThrowPlaylistRefreshError(Uri)}.
+ */
+ public DefaultHlsPlaylistTracker(
+ HlsDataSourceFactory dataSourceFactory,
+ LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+ HlsPlaylistParserFactory playlistParserFactory,
+ double playlistStuckTargetDurationCoefficient) {
+ this.dataSourceFactory = dataSourceFactory;
+ this.playlistParserFactory = playlistParserFactory;
+ this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
+ this.playlistStuckTargetDurationCoefficient = playlistStuckTargetDurationCoefficient;
+ listeners = new ArrayList<>();
+ playlistBundles = new HashMap<>();
+ initialStartTimeUs = C.TIME_UNSET;
+ }
+
+ // HlsPlaylistTracker implementation.
+
+ @Override
+ public void start(
+ Uri initialPlaylistUri,
+ EventDispatcher eventDispatcher,
+ PrimaryPlaylistListener primaryPlaylistListener) {
+ this.playlistRefreshHandler = new Handler();
+ this.eventDispatcher = eventDispatcher;
+ this.primaryPlaylistListener = primaryPlaylistListener;
+ ParsingLoadable<HlsPlaylist> masterPlaylistLoadable =
+ new ParsingLoadable<>(
+ dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST),
+ initialPlaylistUri,
+ C.DATA_TYPE_MANIFEST,
+ playlistParserFactory.createPlaylistParser());
+ Assertions.checkState(initialPlaylistLoader == null);
+ initialPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MasterPlaylist");
+ long elapsedRealtime =
+ initialPlaylistLoader.startLoading(
+ masterPlaylistLoadable,
+ this,
+ loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type));
+ eventDispatcher.loadStarted(
+ masterPlaylistLoadable.dataSpec,
+ masterPlaylistLoadable.type,
+ elapsedRealtime);
+ }
+
+ @Override
+ public void stop() {
+ primaryMediaPlaylistUrl = null;
+ primaryMediaPlaylistSnapshot = null;
+ masterPlaylist = null;
+ initialStartTimeUs = C.TIME_UNSET;
+ initialPlaylistLoader.release();
+ initialPlaylistLoader = null;
+ for (MediaPlaylistBundle bundle : playlistBundles.values()) {
+ bundle.release();
+ }
+ playlistRefreshHandler.removeCallbacksAndMessages(null);
+ playlistRefreshHandler = null;
+ playlistBundles.clear();
+ }
+
+ @Override
+ public void addListener(PlaylistEventListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ public void removeListener(PlaylistEventListener listener) {
+ listeners.remove(listener);
+ }
+
+ @Override
+ @Nullable
+ public HlsMasterPlaylist getMasterPlaylist() {
+ return masterPlaylist;
+ }
+
+ @Override
+ @Nullable
+ public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) {
+ HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot();
+ if (snapshot != null && isForPlayback) {
+ maybeSetPrimaryUrl(url);
+ }
+ return snapshot;
+ }
+
+ @Override
+ public long getInitialStartTimeUs() {
+ return initialStartTimeUs;
+ }
+
+ @Override
+ public boolean isSnapshotValid(Uri url) {
+ return playlistBundles.get(url).isSnapshotValid();
+ }
+
+ @Override
+ public void maybeThrowPrimaryPlaylistRefreshError() throws IOException {
+ if (initialPlaylistLoader != null) {
+ initialPlaylistLoader.maybeThrowError();
+ }
+ if (primaryMediaPlaylistUrl != null) {
+ maybeThrowPlaylistRefreshError(primaryMediaPlaylistUrl);
+ }
+ }
+
+ @Override
+ public void maybeThrowPlaylistRefreshError(Uri url) throws IOException {
+ playlistBundles.get(url).maybeThrowPlaylistRefreshError();
+ }
+
+ @Override
+ public void refreshPlaylist(Uri url) {
+ playlistBundles.get(url).loadPlaylist();
+ }
+
+ @Override
+ public boolean isLive() {
+ return isLive;
+ }
+
+ // Loader.Callback implementation.
+
+ @Override
+ public void onLoadCompleted(
+ ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) {
+ HlsPlaylist result = loadable.getResult();
+ HlsMasterPlaylist masterPlaylist;
+ boolean isMediaPlaylist = result instanceof HlsMediaPlaylist;
+ if (isMediaPlaylist) {
+ masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri);
+ } else /* result instanceof HlsMasterPlaylist */ {
+ masterPlaylist = (HlsMasterPlaylist) result;
+ }
+ this.masterPlaylist = masterPlaylist;
+ mediaPlaylistParser = playlistParserFactory.createPlaylistParser(masterPlaylist);
+ primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url;
+ createBundles(masterPlaylist.mediaPlaylistUrls);
+ MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl);
+ if (isMediaPlaylist) {
+ // We don't need to load the playlist again. We can use the same result.
+ primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs);
+ } else {
+ primaryBundle.loadPlaylist();
+ }
+ eventDispatcher.loadCompleted(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ C.DATA_TYPE_MANIFEST,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded());
+ }
+
+ @Override
+ public void onLoadCanceled(
+ ParsingLoadable<HlsPlaylist> loadable,
+ long elapsedRealtimeMs,
+ long loadDurationMs,
+ boolean released) {
+ eventDispatcher.loadCanceled(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ C.DATA_TYPE_MANIFEST,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded());
+ }
+
+ @Override
+ public LoadErrorAction onLoadError(
+ ParsingLoadable<HlsPlaylist> loadable,
+ long elapsedRealtimeMs,
+ long loadDurationMs,
+ IOException error,
+ int errorCount) {
+ long retryDelayMs =
+ loadErrorHandlingPolicy.getRetryDelayMsFor(
+ loadable.type, loadDurationMs, error, errorCount);
+ boolean isFatal = retryDelayMs == C.TIME_UNSET;
+ eventDispatcher.loadError(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ C.DATA_TYPE_MANIFEST,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded(),
+ error,
+ isFatal);
+ return isFatal
+ ? Loader.DONT_RETRY_FATAL
+ : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs);
+ }
+
+ // Internal methods.
+
+ private boolean maybeSelectNewPrimaryUrl() {
+ List<Variant> variants = masterPlaylist.variants;
+ int variantsSize = variants.size();
+ long currentTimeMs = SystemClock.elapsedRealtime();
+ for (int i = 0; i < variantsSize; i++) {
+ MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i).url);
+ if (currentTimeMs > bundle.blacklistUntilMs) {
+ primaryMediaPlaylistUrl = bundle.playlistUrl;
+ bundle.loadPlaylist();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void maybeSetPrimaryUrl(Uri url) {
+ if (url.equals(primaryMediaPlaylistUrl)
+ || !isVariantUrl(url)
+ || (primaryMediaPlaylistSnapshot != null && primaryMediaPlaylistSnapshot.hasEndTag)) {
+ // Ignore if the primary media playlist URL is unchanged, if the media playlist is not
+ // referenced directly by a variant, or it the last primary snapshot contains an end tag.
+ return;
+ }
+ primaryMediaPlaylistUrl = url;
+ playlistBundles.get(primaryMediaPlaylistUrl).loadPlaylist();
+ }
+
+ /** Returns whether any of the variants in the master playlist have the specified playlist URL. */
+ private boolean isVariantUrl(Uri playlistUrl) {
+ List<Variant> variants = masterPlaylist.variants;
+ for (int i = 0; i < variants.size(); i++) {
+ if (playlistUrl.equals(variants.get(i).url)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void createBundles(List<Uri> urls) {
+ int listSize = urls.size();
+ for (int i = 0; i < listSize; i++) {
+ Uri url = urls.get(i);
+ MediaPlaylistBundle bundle = new MediaPlaylistBundle(url);
+ playlistBundles.put(url, bundle);
+ }
+ }
+
+ /**
+ * Called by the bundles when a snapshot changes.
+ *
+ * @param url The url of the playlist.
+ * @param newSnapshot The new snapshot.
+ */
+ private void onPlaylistUpdated(Uri url, HlsMediaPlaylist newSnapshot) {
+ if (url.equals(primaryMediaPlaylistUrl)) {
+ if (primaryMediaPlaylistSnapshot == null) {
+ // This is the first primary url snapshot.
+ isLive = !newSnapshot.hasEndTag;
+ initialStartTimeUs = newSnapshot.startTimeUs;
+ }
+ primaryMediaPlaylistSnapshot = newSnapshot;
+ primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot);
+ }
+ int listenersSize = listeners.size();
+ for (int i = 0; i < listenersSize; i++) {
+ listeners.get(i).onPlaylistChanged();
+ }
+ }
+
+ private boolean notifyPlaylistError(Uri playlistUrl, long blacklistDurationMs) {
+ int listenersSize = listeners.size();
+ boolean anyBlacklistingFailed = false;
+ for (int i = 0; i < listenersSize; i++) {
+ anyBlacklistingFailed |= !listeners.get(i).onPlaylistError(playlistUrl, blacklistDurationMs);
+ }
+ return anyBlacklistingFailed;
+ }
+
+ private HlsMediaPlaylist getLatestPlaylistSnapshot(
+ HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
+ if (!loadedPlaylist.isNewerThan(oldPlaylist)) {
+ if (loadedPlaylist.hasEndTag) {
+ // If the loaded playlist has an end tag but is not newer than the old playlist then we have
+ // an inconsistent state. This is typically caused by the server incorrectly resetting the
+ // media sequence when appending the end tag. We resolve this case as best we can by
+ // returning the old playlist with the end tag appended.
+ return oldPlaylist.copyWithEndTag();
+ } else {
+ return oldPlaylist;
+ }
+ }
+ long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist);
+ int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, loadedPlaylist);
+ return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence);
+ }
+
+ private long getLoadedPlaylistStartTimeUs(
+ HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
+ if (loadedPlaylist.hasProgramDateTime) {
+ return loadedPlaylist.startTimeUs;
+ }
+ long primarySnapshotStartTimeUs =
+ primaryMediaPlaylistSnapshot != null ? primaryMediaPlaylistSnapshot.startTimeUs : 0;
+ if (oldPlaylist == null) {
+ return primarySnapshotStartTimeUs;
+ }
+ int oldPlaylistSize = oldPlaylist.segments.size();
+ Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist);
+ if (firstOldOverlappingSegment != null) {
+ return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs;
+ } else if (oldPlaylistSize == loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence) {
+ return oldPlaylist.getEndTimeUs();
+ } else {
+ // No segments overlap, we assume the new playlist start coincides with the primary playlist.
+ return primarySnapshotStartTimeUs;
+ }
+ }
+
+ private int getLoadedPlaylistDiscontinuitySequence(
+ HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
+ if (loadedPlaylist.hasDiscontinuitySequence) {
+ return loadedPlaylist.discontinuitySequence;
+ }
+ // TODO: Improve cross-playlist discontinuity adjustment.
+ int primaryUrlDiscontinuitySequence =
+ primaryMediaPlaylistSnapshot != null
+ ? primaryMediaPlaylistSnapshot.discontinuitySequence
+ : 0;
+ if (oldPlaylist == null) {
+ return primaryUrlDiscontinuitySequence;
+ }
+ Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist);
+ if (firstOldOverlappingSegment != null) {
+ return oldPlaylist.discontinuitySequence
+ + firstOldOverlappingSegment.relativeDiscontinuitySequence
+ - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence;
+ }
+ return primaryUrlDiscontinuitySequence;
+ }
+
+ private static Segment getFirstOldOverlappingSegment(
+ HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
+ int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence);
+ List<Segment> oldSegments = oldPlaylist.segments;
+ return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null;
+ }
+
+ /** Holds all information related to a specific Media Playlist. */
+ private final class MediaPlaylistBundle
+ implements Loader.Callback<ParsingLoadable<HlsPlaylist>>, Runnable {
+
+ private final Uri playlistUrl;
+ private final Loader mediaPlaylistLoader;
+ private final ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable;
+
+ @Nullable private HlsMediaPlaylist playlistSnapshot;
+ private long lastSnapshotLoadMs;
+ private long lastSnapshotChangeMs;
+ private long earliestNextLoadTimeMs;
+ private long blacklistUntilMs;
+ private boolean loadPending;
+ private IOException playlistError;
+
+ public MediaPlaylistBundle(Uri playlistUrl) {
+ this.playlistUrl = playlistUrl;
+ mediaPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MediaPlaylist");
+ mediaPlaylistLoadable =
+ new ParsingLoadable<>(
+ dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST),
+ playlistUrl,
+ C.DATA_TYPE_MANIFEST,
+ mediaPlaylistParser);
+ }
+
+ @Nullable
+ public HlsMediaPlaylist getPlaylistSnapshot() {
+ return playlistSnapshot;
+ }
+
+ public boolean isSnapshotValid() {
+ if (playlistSnapshot == null) {
+ return false;
+ }
+ long currentTimeMs = SystemClock.elapsedRealtime();
+ long snapshotValidityDurationMs = Math.max(30000, C.usToMs(playlistSnapshot.durationUs));
+ return playlistSnapshot.hasEndTag
+ || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT
+ || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD
+ || lastSnapshotLoadMs + snapshotValidityDurationMs > currentTimeMs;
+ }
+
+ public void release() {
+ mediaPlaylistLoader.release();
+ }
+
+ public void loadPlaylist() {
+ blacklistUntilMs = 0;
+ if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) {
+ // Load already pending, in progress, or a fatal error has been encountered. Do nothing.
+ return;
+ }
+ long currentTimeMs = SystemClock.elapsedRealtime();
+ if (currentTimeMs < earliestNextLoadTimeMs) {
+ loadPending = true;
+ playlistRefreshHandler.postDelayed(this, earliestNextLoadTimeMs - currentTimeMs);
+ } else {
+ loadPlaylistImmediately();
+ }
+ }
+
+ public void maybeThrowPlaylistRefreshError() throws IOException {
+ mediaPlaylistLoader.maybeThrowError();
+ if (playlistError != null) {
+ throw playlistError;
+ }
+ }
+
+ // Loader.Callback implementation.
+
+ @Override
+ public void onLoadCompleted(
+ ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) {
+ HlsPlaylist result = loadable.getResult();
+ if (result instanceof HlsMediaPlaylist) {
+ processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs);
+ eventDispatcher.loadCompleted(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ C.DATA_TYPE_MANIFEST,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded());
+ } else {
+ playlistError = new ParserException("Loaded playlist has unexpected type.");
+ }
+ }
+
+ @Override
+ public void onLoadCanceled(
+ ParsingLoadable<HlsPlaylist> loadable,
+ long elapsedRealtimeMs,
+ long loadDurationMs,
+ boolean released) {
+ eventDispatcher.loadCanceled(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ C.DATA_TYPE_MANIFEST,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded());
+ }
+
+ @Override
+ public LoadErrorAction onLoadError(
+ ParsingLoadable<HlsPlaylist> loadable,
+ long elapsedRealtimeMs,
+ long loadDurationMs,
+ IOException error,
+ int errorCount) {
+ LoadErrorAction loadErrorAction;
+
+ long blacklistDurationMs =
+ loadErrorHandlingPolicy.getBlacklistDurationMsFor(
+ loadable.type, loadDurationMs, error, errorCount);
+ boolean shouldBlacklist = blacklistDurationMs != C.TIME_UNSET;
+
+ boolean blacklistingFailed =
+ notifyPlaylistError(playlistUrl, blacklistDurationMs) || !shouldBlacklist;
+ if (shouldBlacklist) {
+ blacklistingFailed |= blacklistPlaylist(blacklistDurationMs);
+ }
+
+ if (blacklistingFailed) {
+ long retryDelay =
+ loadErrorHandlingPolicy.getRetryDelayMsFor(
+ loadable.type, loadDurationMs, error, errorCount);
+ loadErrorAction =
+ retryDelay != C.TIME_UNSET
+ ? Loader.createRetryAction(false, retryDelay)
+ : Loader.DONT_RETRY_FATAL;
+ } else {
+ loadErrorAction = Loader.DONT_RETRY;
+ }
+
+ eventDispatcher.loadError(
+ loadable.dataSpec,
+ loadable.getUri(),
+ loadable.getResponseHeaders(),
+ C.DATA_TYPE_MANIFEST,
+ elapsedRealtimeMs,
+ loadDurationMs,
+ loadable.bytesLoaded(),
+ error,
+ /* wasCanceled= */ !loadErrorAction.isRetry());
+
+ return loadErrorAction;
+ }
+
+ // Runnable implementation.
+
+ @Override
+ public void run() {
+ loadPending = false;
+ loadPlaylistImmediately();
+ }
+
+ // Internal methods.
+
+ private void loadPlaylistImmediately() {
+ long elapsedRealtime =
+ mediaPlaylistLoader.startLoading(
+ mediaPlaylistLoadable,
+ this,
+ loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type));
+ eventDispatcher.loadStarted(
+ mediaPlaylistLoadable.dataSpec,
+ mediaPlaylistLoadable.type,
+ elapsedRealtime);
+ }
+
+ private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) {
+ HlsMediaPlaylist oldPlaylist = playlistSnapshot;
+ long currentTimeMs = SystemClock.elapsedRealtime();
+ lastSnapshotLoadMs = currentTimeMs;
+ playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist);
+ if (playlistSnapshot != oldPlaylist) {
+ playlistError = null;
+ lastSnapshotChangeMs = currentTimeMs;
+ onPlaylistUpdated(playlistUrl, playlistSnapshot);
+ } else if (!playlistSnapshot.hasEndTag) {
+ if (loadedPlaylist.mediaSequence + loadedPlaylist.segments.size()
+ < playlistSnapshot.mediaSequence) {
+ // TODO: Allow customization of playlist resets handling.
+ // The media sequence jumped backwards. The server has probably reset. We do not try
+ // blacklisting in this case.
+ playlistError = new PlaylistResetException(playlistUrl);
+ notifyPlaylistError(playlistUrl, C.TIME_UNSET);
+ } else if (currentTimeMs - lastSnapshotChangeMs
+ > C.usToMs(playlistSnapshot.targetDurationUs)
+ * playlistStuckTargetDurationCoefficient) {
+ // TODO: Allow customization of stuck playlists handling.
+ playlistError = new PlaylistStuckException(playlistUrl);
+ long blacklistDurationMs =
+ loadErrorHandlingPolicy.getBlacklistDurationMsFor(
+ C.DATA_TYPE_MANIFEST, loadDurationMs, playlistError, /* errorCount= */ 1);
+ notifyPlaylistError(playlistUrl, blacklistDurationMs);
+ if (blacklistDurationMs != C.TIME_UNSET) {
+ blacklistPlaylist(blacklistDurationMs);
+ }
+ }
+ }
+ // Do not allow the playlist to load again within the target duration if we obtained a new
+ // snapshot, or half the target duration otherwise.
+ earliestNextLoadTimeMs =
+ currentTimeMs
+ + C.usToMs(
+ playlistSnapshot != oldPlaylist
+ ? playlistSnapshot.targetDurationUs
+ : (playlistSnapshot.targetDurationUs / 2));
+ // Schedule a load if this is the primary playlist and it doesn't have an end tag. Else the
+ // next load will be scheduled when refreshPlaylist is called, or when this playlist becomes
+ // the primary.
+ if (playlistUrl.equals(primaryMediaPlaylistUrl) && !playlistSnapshot.hasEndTag) {
+ loadPlaylist();
+ }
+ }
+
+ /**
+ * Blacklists the playlist.
+ *
+ * @param blacklistDurationMs The number of milliseconds for which the playlist should be
+ * blacklisted.
+ * @return Whether the playlist is the primary, despite being blacklisted.
+ */
+ private boolean blacklistPlaylist(long blacklistDurationMs) {
+ blacklistUntilMs = SystemClock.elapsedRealtime() + blacklistDurationMs;
+ return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl();
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java
new file mode 100644
index 0000000000..a8c9ea1756
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilteringManifestParser;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable;
+import java.util.List;
+
+/**
+ * A {@link HlsPlaylistParserFactory} that includes only the streams identified by the given stream
+ * keys.
+ */
+public final class FilteringHlsPlaylistParserFactory implements HlsPlaylistParserFactory {
+
+ private final HlsPlaylistParserFactory hlsPlaylistParserFactory;
+ private final List<StreamKey> streamKeys;
+
+ /**
+ * @param hlsPlaylistParserFactory A factory for the parsers of the playlists which will be
+ * filtered.
+ * @param streamKeys The stream keys. If null or empty then filtering will not occur.
+ */
+ public FilteringHlsPlaylistParserFactory(
+ HlsPlaylistParserFactory hlsPlaylistParserFactory, List<StreamKey> streamKeys) {
+ this.hlsPlaylistParserFactory = hlsPlaylistParserFactory;
+ this.streamKeys = streamKeys;
+ }
+
+ @Override
+ public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() {
+ return new FilteringManifestParser<>(
+ hlsPlaylistParserFactory.createPlaylistParser(), streamKeys);
+ }
+
+ @Override
+ public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(
+ HlsMasterPlaylist masterPlaylist) {
+ return new FilteringManifestParser<>(
+ hlsPlaylistParserFactory.createPlaylistParser(masterPlaylist), streamKeys);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java
new file mode 100644
index 0000000000..376f2b4301
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java
@@ -0,0 +1,330 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/** Represents an HLS master playlist. */
+public final class HlsMasterPlaylist extends HlsPlaylist {
+
+ /** Represents an empty master playlist, from which no attributes can be inherited. */
+ public static final HlsMasterPlaylist EMPTY =
+ new HlsMasterPlaylist(
+ /* baseUri= */ "",
+ /* tags= */ Collections.emptyList(),
+ /* variants= */ Collections.emptyList(),
+ /* videos= */ Collections.emptyList(),
+ /* audios= */ Collections.emptyList(),
+ /* subtitles= */ Collections.emptyList(),
+ /* closedCaptions= */ Collections.emptyList(),
+ /* muxedAudioFormat= */ null,
+ /* muxedCaptionFormats= */ Collections.emptyList(),
+ /* hasIndependentSegments= */ false,
+ /* variableDefinitions= */ Collections.emptyMap(),
+ /* sessionKeyDrmInitData= */ Collections.emptyList());
+
+ // These constants must not be changed because they are persisted in offline stream keys.
+ public static final int GROUP_INDEX_VARIANT = 0;
+ public static final int GROUP_INDEX_AUDIO = 1;
+ public static final int GROUP_INDEX_SUBTITLE = 2;
+
+ /** A variant (i.e. an #EXT-X-STREAM-INF tag) in a master playlist. */
+ public static final class Variant {
+
+ /** The variant's url. */
+ public final Uri url;
+
+ /** Format information associated with this variant. */
+ public final Format format;
+
+ /** The video rendition group referenced by this variant, or {@code null}. */
+ @Nullable public final String videoGroupId;
+
+ /** The audio rendition group referenced by this variant, or {@code null}. */
+ @Nullable public final String audioGroupId;
+
+ /** The subtitle rendition group referenced by this variant, or {@code null}. */
+ @Nullable public final String subtitleGroupId;
+
+ /** The caption rendition group referenced by this variant, or {@code null}. */
+ @Nullable public final String captionGroupId;
+
+ /**
+ * @param url See {@link #url}.
+ * @param format See {@link #format}.
+ * @param videoGroupId See {@link #videoGroupId}.
+ * @param audioGroupId See {@link #audioGroupId}.
+ * @param subtitleGroupId See {@link #subtitleGroupId}.
+ * @param captionGroupId See {@link #captionGroupId}.
+ */
+ public Variant(
+ Uri url,
+ Format format,
+ @Nullable String videoGroupId,
+ @Nullable String audioGroupId,
+ @Nullable String subtitleGroupId,
+ @Nullable String captionGroupId) {
+ this.url = url;
+ this.format = format;
+ this.videoGroupId = videoGroupId;
+ this.audioGroupId = audioGroupId;
+ this.subtitleGroupId = subtitleGroupId;
+ this.captionGroupId = captionGroupId;
+ }
+
+ /**
+ * Creates a variant for a given media playlist url.
+ *
+ * @param url The media playlist url.
+ * @return The variant instance.
+ */
+ public static Variant createMediaPlaylistVariantUrl(Uri url) {
+ Format format =
+ Format.createContainerFormat(
+ "0",
+ /* label= */ null,
+ MimeTypes.APPLICATION_M3U8,
+ /* sampleMimeType= */ null,
+ /* codecs= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ /* selectionFlags= */ 0,
+ /* roleFlags= */ 0,
+ /* language= */ null);
+ return new Variant(
+ url,
+ format,
+ /* videoGroupId= */ null,
+ /* audioGroupId= */ null,
+ /* subtitleGroupId= */ null,
+ /* captionGroupId= */ null);
+ }
+
+ /** Returns a copy of this instance with the given {@link Format}. */
+ public Variant copyWithFormat(Format format) {
+ return new Variant(url, format, videoGroupId, audioGroupId, subtitleGroupId, captionGroupId);
+ }
+ }
+
+ /** A rendition (i.e. an #EXT-X-MEDIA tag) in a master playlist. */
+ public static final class Rendition {
+
+ /** The rendition's url, or null if the tag does not have a URI attribute. */
+ @Nullable public final Uri url;
+
+ /** Format information associated with this rendition. */
+ public final Format format;
+
+ /** The group to which this rendition belongs. */
+ public final String groupId;
+
+ /** The name of the rendition. */
+ public final String name;
+
+ /**
+ * @param url See {@link #url}.
+ * @param format See {@link #format}.
+ * @param groupId See {@link #groupId}.
+ * @param name See {@link #name}.
+ */
+ public Rendition(@Nullable Uri url, Format format, String groupId, String name) {
+ this.url = url;
+ this.format = format;
+ this.groupId = groupId;
+ this.name = name;
+ }
+
+ }
+
+ /** All of the media playlist URLs referenced by the playlist. */
+ public final List<Uri> mediaPlaylistUrls;
+ /** The variants declared by the playlist. */
+ public final List<Variant> variants;
+ /** The video renditions declared by the playlist. */
+ public final List<Rendition> videos;
+ /** The audio renditions declared by the playlist. */
+ public final List<Rendition> audios;
+ /** The subtitle renditions declared by the playlist. */
+ public final List<Rendition> subtitles;
+ /** The closed caption renditions declared by the playlist. */
+ public final List<Rendition> closedCaptions;
+
+ /**
+ * The format of the audio muxed in the variants. May be null if the playlist does not declare any
+ * muxed audio.
+ */
+ @Nullable public final Format muxedAudioFormat;
+ /**
+ * The format of the closed captions declared by the playlist. May be empty if the playlist
+ * explicitly declares no captions are available, or null if the playlist does not declare any
+ * captions information.
+ */
+ @Nullable public final List<Format> muxedCaptionFormats;
+ /** Contains variable definitions, as defined by the #EXT-X-DEFINE tag. */
+ public final Map<String, String> variableDefinitions;
+ /** DRM initialization data derived from #EXT-X-SESSION-KEY tags. */
+ public final List<DrmInitData> sessionKeyDrmInitData;
+
+ /**
+ * @param baseUri See {@link #baseUri}.
+ * @param tags See {@link #tags}.
+ * @param variants See {@link #variants}.
+ * @param videos See {@link #videos}.
+ * @param audios See {@link #audios}.
+ * @param subtitles See {@link #subtitles}.
+ * @param closedCaptions See {@link #closedCaptions}.
+ * @param muxedAudioFormat See {@link #muxedAudioFormat}.
+ * @param muxedCaptionFormats See {@link #muxedCaptionFormats}.
+ * @param hasIndependentSegments See {@link #hasIndependentSegments}.
+ * @param variableDefinitions See {@link #variableDefinitions}.
+ * @param sessionKeyDrmInitData See {@link #sessionKeyDrmInitData}.
+ */
+ public HlsMasterPlaylist(
+ String baseUri,
+ List<String> tags,
+ List<Variant> variants,
+ List<Rendition> videos,
+ List<Rendition> audios,
+ List<Rendition> subtitles,
+ List<Rendition> closedCaptions,
+ @Nullable Format muxedAudioFormat,
+ @Nullable List<Format> muxedCaptionFormats,
+ boolean hasIndependentSegments,
+ Map<String, String> variableDefinitions,
+ List<DrmInitData> sessionKeyDrmInitData) {
+ super(baseUri, tags, hasIndependentSegments);
+ this.mediaPlaylistUrls =
+ Collections.unmodifiableList(
+ getMediaPlaylistUrls(variants, videos, audios, subtitles, closedCaptions));
+ this.variants = Collections.unmodifiableList(variants);
+ this.videos = Collections.unmodifiableList(videos);
+ this.audios = Collections.unmodifiableList(audios);
+ this.subtitles = Collections.unmodifiableList(subtitles);
+ this.closedCaptions = Collections.unmodifiableList(closedCaptions);
+ this.muxedAudioFormat = muxedAudioFormat;
+ this.muxedCaptionFormats = muxedCaptionFormats != null
+ ? Collections.unmodifiableList(muxedCaptionFormats) : null;
+ this.variableDefinitions = Collections.unmodifiableMap(variableDefinitions);
+ this.sessionKeyDrmInitData = Collections.unmodifiableList(sessionKeyDrmInitData);
+ }
+
+ @Override
+ public HlsMasterPlaylist copy(List<StreamKey> streamKeys) {
+ return new HlsMasterPlaylist(
+ baseUri,
+ tags,
+ copyStreams(variants, GROUP_INDEX_VARIANT, streamKeys),
+ // TODO: Allow stream keys to specify video renditions to be retained.
+ /* videos= */ Collections.emptyList(),
+ copyStreams(audios, GROUP_INDEX_AUDIO, streamKeys),
+ copyStreams(subtitles, GROUP_INDEX_SUBTITLE, streamKeys),
+ // TODO: Update to retain all closed captions.
+ /* closedCaptions= */ Collections.emptyList(),
+ muxedAudioFormat,
+ muxedCaptionFormats,
+ hasIndependentSegments,
+ variableDefinitions,
+ sessionKeyDrmInitData);
+ }
+
+ /**
+ * Creates a playlist with a single variant.
+ *
+ * @param variantUrl The url of the single variant.
+ * @return A master playlist with a single variant for the provided url.
+ */
+ public static HlsMasterPlaylist createSingleVariantMasterPlaylist(String variantUrl) {
+ List<Variant> variant =
+ Collections.singletonList(Variant.createMediaPlaylistVariantUrl(Uri.parse(variantUrl)));
+ return new HlsMasterPlaylist(
+ /* baseUri= */ "",
+ /* tags= */ Collections.emptyList(),
+ variant,
+ /* videos= */ Collections.emptyList(),
+ /* audios= */ Collections.emptyList(),
+ /* subtitles= */ Collections.emptyList(),
+ /* closedCaptions= */ Collections.emptyList(),
+ /* muxedAudioFormat= */ null,
+ /* muxedCaptionFormats= */ null,
+ /* hasIndependentSegments= */ false,
+ /* variableDefinitions= */ Collections.emptyMap(),
+ /* sessionKeyDrmInitData= */ Collections.emptyList());
+ }
+
+ private static List<Uri> getMediaPlaylistUrls(
+ List<Variant> variants,
+ List<Rendition> videos,
+ List<Rendition> audios,
+ List<Rendition> subtitles,
+ List<Rendition> closedCaptions) {
+ ArrayList<Uri> mediaPlaylistUrls = new ArrayList<>();
+ for (int i = 0; i < variants.size(); i++) {
+ Uri uri = variants.get(i).url;
+ if (!mediaPlaylistUrls.contains(uri)) {
+ mediaPlaylistUrls.add(uri);
+ }
+ }
+ addMediaPlaylistUrls(videos, mediaPlaylistUrls);
+ addMediaPlaylistUrls(audios, mediaPlaylistUrls);
+ addMediaPlaylistUrls(subtitles, mediaPlaylistUrls);
+ addMediaPlaylistUrls(closedCaptions, mediaPlaylistUrls);
+ return mediaPlaylistUrls;
+ }
+
+ private static void addMediaPlaylistUrls(List<Rendition> renditions, List<Uri> out) {
+ for (int i = 0; i < renditions.size(); i++) {
+ Uri uri = renditions.get(i).url;
+ if (uri != null && !out.contains(uri)) {
+ out.add(uri);
+ }
+ }
+ }
+
+ private static <T> List<T> copyStreams(
+ List<T> streams, int groupIndex, List<StreamKey> streamKeys) {
+ List<T> copiedStreams = new ArrayList<>(streamKeys.size());
+ // TODO:
+ // 1. When variants with the same URL are not de-duplicated, duplicates must not increment
+ // trackIndex so as to avoid breaking stream keys that have been persisted for offline. All
+ // duplicates should be copied if the first variant is copied, or discarded otherwise.
+ // 2. When renditions with null URLs are permitted, they must not increment trackIndex so as to
+ // avoid breaking stream keys that have been persisted for offline. All renitions with null
+ // URLs should be copied. They may become unreachable if all variants that reference them are
+ // removed, but this is OK.
+ // 3. Renditions with URLs matching copied variants should always themselves be copied, even if
+ // the corresponding stream key is omitted. Else we're throwing away information for no gain.
+ for (int i = 0; i < streams.size(); i++) {
+ T stream = streams.get(i);
+ for (int j = 0; j < streamKeys.size(); j++) {
+ StreamKey streamKey = streamKeys.get(j);
+ if (streamKey.groupIndex == groupIndex && streamKey.trackIndex == i) {
+ copiedStreams.add(stream);
+ break;
+ }
+ }
+ }
+ return copiedStreams;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java
new file mode 100644
index 0000000000..c3250a5cc0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java
@@ -0,0 +1,375 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collections;
+import java.util.List;
+
+/** Represents an HLS media playlist. */
+public final class HlsMediaPlaylist extends HlsPlaylist {
+
+ /** Media segment reference. */
+ @SuppressWarnings("ComparableType")
+ public static final class Segment implements Comparable<Long> {
+
+ /**
+ * The url of the segment.
+ */
+ public final String url;
+ /**
+ * The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if
+ * the media playlist does not define a media section for this segment. The same instance is
+ * used for all segments that share an EXT-X-MAP tag.
+ */
+ @Nullable public final Segment initializationSegment;
+ /** The duration of the segment in microseconds, as defined by #EXTINF. */
+ public final long durationUs;
+ /** The human readable title of the segment. */
+ public final String title;
+ /**
+ * The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment.
+ */
+ public final int relativeDiscontinuitySequence;
+ /**
+ * The start time of the segment in microseconds, relative to the start of the playlist.
+ */
+ public final long relativeStartTimeUs;
+ /**
+ * DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM
+ * protection.
+ */
+ @Nullable public final DrmInitData drmInitData;
+ /**
+ * The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use
+ * full segment encryption with identity key.
+ */
+ @Nullable public final String fullSegmentEncryptionKeyUri;
+ /**
+ * The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not
+ * encrypted.
+ */
+ @Nullable public final String encryptionIV;
+ /**
+ * The segment's byte range offset, as defined by #EXT-X-BYTERANGE.
+ */
+ public final long byterangeOffset;
+ /**
+ * The segment's byte range length, as defined by #EXT-X-BYTERANGE, or {@link C#LENGTH_UNSET} if
+ * no byte range is specified.
+ */
+ public final long byterangeLength;
+
+ /** Whether the segment is tagged with #EXT-X-GAP. */
+ public final boolean hasGapTag;
+
+ /**
+ * @param uri See {@link #url}.
+ * @param byterangeOffset See {@link #byterangeOffset}.
+ * @param byterangeLength See {@link #byterangeLength}.
+ * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}.
+ * @param encryptionIV See {@link #encryptionIV}.
+ */
+ public Segment(
+ String uri,
+ long byterangeOffset,
+ long byterangeLength,
+ @Nullable String fullSegmentEncryptionKeyUri,
+ @Nullable String encryptionIV) {
+ this(
+ uri,
+ /* initializationSegment= */ null,
+ /* title= */ "",
+ /* durationUs= */ 0,
+ /* relativeDiscontinuitySequence= */ -1,
+ /* relativeStartTimeUs= */ C.TIME_UNSET,
+ /* drmInitData= */ null,
+ fullSegmentEncryptionKeyUri,
+ encryptionIV,
+ byterangeOffset,
+ byterangeLength,
+ /* hasGapTag= */ false);
+ }
+
+ /**
+ * @param url See {@link #url}.
+ * @param initializationSegment See {@link #initializationSegment}.
+ * @param title See {@link #title}.
+ * @param durationUs See {@link #durationUs}.
+ * @param relativeDiscontinuitySequence See {@link #relativeDiscontinuitySequence}.
+ * @param relativeStartTimeUs See {@link #relativeStartTimeUs}.
+ * @param drmInitData See {@link #drmInitData}.
+ * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}.
+ * @param encryptionIV See {@link #encryptionIV}.
+ * @param byterangeOffset See {@link #byterangeOffset}.
+ * @param byterangeLength See {@link #byterangeLength}.
+ * @param hasGapTag See {@link #hasGapTag}.
+ */
+ public Segment(
+ String url,
+ @Nullable Segment initializationSegment,
+ String title,
+ long durationUs,
+ int relativeDiscontinuitySequence,
+ long relativeStartTimeUs,
+ @Nullable DrmInitData drmInitData,
+ @Nullable String fullSegmentEncryptionKeyUri,
+ @Nullable String encryptionIV,
+ long byterangeOffset,
+ long byterangeLength,
+ boolean hasGapTag) {
+ this.url = url;
+ this.initializationSegment = initializationSegment;
+ this.title = title;
+ this.durationUs = durationUs;
+ this.relativeDiscontinuitySequence = relativeDiscontinuitySequence;
+ this.relativeStartTimeUs = relativeStartTimeUs;
+ this.drmInitData = drmInitData;
+ this.fullSegmentEncryptionKeyUri = fullSegmentEncryptionKeyUri;
+ this.encryptionIV = encryptionIV;
+ this.byterangeOffset = byterangeOffset;
+ this.byterangeLength = byterangeLength;
+ this.hasGapTag = hasGapTag;
+ }
+
+ @Override
+ public int compareTo(Long relativeStartTimeUs) {
+ return this.relativeStartTimeUs > relativeStartTimeUs
+ ? 1 : (this.relativeStartTimeUs < relativeStartTimeUs ? -1 : 0);
+ }
+
+ }
+
+ /**
+ * 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}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({PLAYLIST_TYPE_UNKNOWN, PLAYLIST_TYPE_VOD, PLAYLIST_TYPE_EVENT})
+ public @interface PlaylistType {}
+
+ public static final int PLAYLIST_TYPE_UNKNOWN = 0;
+ public static final int PLAYLIST_TYPE_VOD = 1;
+ public static final int PLAYLIST_TYPE_EVENT = 2;
+
+ /**
+ * The type of the playlist. See {@link PlaylistType}.
+ */
+ @PlaylistType public final int playlistType;
+ /**
+ * The start offset in microseconds, as defined by #EXT-X-START.
+ */
+ public final long startOffsetUs;
+ /**
+ * If {@link #hasProgramDateTime} is true, contains the datetime as microseconds since epoch.
+ * Otherwise, contains the aggregated duration of removed segments up to this snapshot of the
+ * playlist.
+ */
+ public final long startTimeUs;
+ /**
+ * Whether the playlist contains the #EXT-X-DISCONTINUITY-SEQUENCE tag.
+ */
+ public final boolean hasDiscontinuitySequence;
+ /**
+ * The discontinuity sequence number of the first media segment in the playlist, as defined by
+ * #EXT-X-DISCONTINUITY-SEQUENCE.
+ */
+ public final int discontinuitySequence;
+ /**
+ * The media sequence number of the first media segment in the playlist, as defined by
+ * #EXT-X-MEDIA-SEQUENCE.
+ */
+ public final long mediaSequence;
+ /**
+ * The compatibility version, as defined by #EXT-X-VERSION.
+ */
+ public final int version;
+ /**
+ * The target duration in microseconds, as defined by #EXT-X-TARGETDURATION.
+ */
+ public final long targetDurationUs;
+ /**
+ * Whether the playlist contains the #EXT-X-ENDLIST tag.
+ */
+ public final boolean hasEndTag;
+ /**
+ * Whether the playlist contains a #EXT-X-PROGRAM-DATE-TIME tag.
+ */
+ public final boolean hasProgramDateTime;
+ /**
+ * Contains the CDM protection schemes used by segments in this playlist. Does not contain any key
+ * acquisition data. Null if none of the segments in the playlist is CDM-encrypted.
+ */
+ @Nullable public final DrmInitData protectionSchemes;
+ /**
+ * The list of segments in the playlist.
+ */
+ public final List<Segment> segments;
+ /**
+ * The total duration of the playlist in microseconds.
+ */
+ public final long durationUs;
+
+ /**
+ * @param playlistType See {@link #playlistType}.
+ * @param baseUri See {@link #baseUri}.
+ * @param tags See {@link #tags}.
+ * @param startOffsetUs See {@link #startOffsetUs}.
+ * @param startTimeUs See {@link #startTimeUs}.
+ * @param hasDiscontinuitySequence See {@link #hasDiscontinuitySequence}.
+ * @param discontinuitySequence See {@link #discontinuitySequence}.
+ * @param mediaSequence See {@link #mediaSequence}.
+ * @param version See {@link #version}.
+ * @param targetDurationUs See {@link #targetDurationUs}.
+ * @param hasIndependentSegments See {@link #hasIndependentSegments}.
+ * @param hasEndTag See {@link #hasEndTag}.
+ * @param protectionSchemes See {@link #protectionSchemes}.
+ * @param hasProgramDateTime See {@link #hasProgramDateTime}.
+ * @param segments See {@link #segments}.
+ */
+ public HlsMediaPlaylist(
+ @PlaylistType int playlistType,
+ String baseUri,
+ List<String> tags,
+ long startOffsetUs,
+ long startTimeUs,
+ boolean hasDiscontinuitySequence,
+ int discontinuitySequence,
+ long mediaSequence,
+ int version,
+ long targetDurationUs,
+ boolean hasIndependentSegments,
+ boolean hasEndTag,
+ boolean hasProgramDateTime,
+ @Nullable DrmInitData protectionSchemes,
+ List<Segment> segments) {
+ super(baseUri, tags, hasIndependentSegments);
+ this.playlistType = playlistType;
+ this.startTimeUs = startTimeUs;
+ this.hasDiscontinuitySequence = hasDiscontinuitySequence;
+ this.discontinuitySequence = discontinuitySequence;
+ this.mediaSequence = mediaSequence;
+ this.version = version;
+ this.targetDurationUs = targetDurationUs;
+ this.hasEndTag = hasEndTag;
+ this.hasProgramDateTime = hasProgramDateTime;
+ this.protectionSchemes = protectionSchemes;
+ this.segments = Collections.unmodifiableList(segments);
+ if (!segments.isEmpty()) {
+ Segment last = segments.get(segments.size() - 1);
+ durationUs = last.relativeStartTimeUs + last.durationUs;
+ } else {
+ durationUs = 0;
+ }
+ this.startOffsetUs = startOffsetUs == C.TIME_UNSET ? C.TIME_UNSET
+ : startOffsetUs >= 0 ? startOffsetUs : durationUs + startOffsetUs;
+ }
+
+ @Override
+ public HlsMediaPlaylist copy(List<StreamKey> streamKeys) {
+ return this;
+ }
+
+ /**
+ * Returns whether this playlist is newer than {@code other}.
+ *
+ * @param other The playlist to compare.
+ * @return Whether this playlist is newer than {@code other}.
+ */
+ public boolean isNewerThan(HlsMediaPlaylist other) {
+ if (other == null || mediaSequence > other.mediaSequence) {
+ return true;
+ }
+ if (mediaSequence < other.mediaSequence) {
+ return false;
+ }
+ // The media sequences are equal.
+ int segmentCount = segments.size();
+ int otherSegmentCount = other.segments.size();
+ return segmentCount > otherSegmentCount
+ || (segmentCount == otherSegmentCount && hasEndTag && !other.hasEndTag);
+ }
+
+ /**
+ * Returns the result of adding the duration of the playlist to its start time.
+ */
+ public long getEndTimeUs() {
+ return startTimeUs + durationUs;
+ }
+
+ /**
+ * Returns a playlist identical to this one except for the start time, the discontinuity sequence
+ * and {@code hasDiscontinuitySequence} values. The first two are set to the specified values,
+ * {@code hasDiscontinuitySequence} is set to true.
+ *
+ * @param startTimeUs The start time for the returned playlist.
+ * @param discontinuitySequence The discontinuity sequence for the returned playlist.
+ * @return An identical playlist including the provided discontinuity and timing information.
+ */
+ public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) {
+ return new HlsMediaPlaylist(
+ playlistType,
+ baseUri,
+ tags,
+ startOffsetUs,
+ startTimeUs,
+ /* hasDiscontinuitySequence= */ true,
+ discontinuitySequence,
+ mediaSequence,
+ version,
+ targetDurationUs,
+ hasIndependentSegments,
+ hasEndTag,
+ hasProgramDateTime,
+ protectionSchemes,
+ segments);
+ }
+
+ /**
+ * Returns a playlist identical to this one except that an end tag is added. If an end tag is
+ * already present then the playlist will return itself.
+ */
+ public HlsMediaPlaylist copyWithEndTag() {
+ if (this.hasEndTag) {
+ return this;
+ }
+ return new HlsMediaPlaylist(
+ playlistType,
+ baseUri,
+ tags,
+ startOffsetUs,
+ startTimeUs,
+ hasDiscontinuitySequence,
+ discontinuitySequence,
+ mediaSequence,
+ version,
+ targetDurationUs,
+ hasIndependentSegments,
+ /* hasEndTag= */ true,
+ hasProgramDateTime,
+ protectionSchemes,
+ segments);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java
new file mode 100644
index 0000000000..28f9b0eeb0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilterableManifest;
+import java.util.Collections;
+import java.util.List;
+
+/** Represents an HLS playlist. */
+public abstract class HlsPlaylist implements FilterableManifest<HlsPlaylist> {
+
+ /**
+ * The base uri. Used to resolve relative paths.
+ */
+ public final String baseUri;
+ /**
+ * The list of tags in the playlist.
+ */
+ public final List<String> tags;
+ /**
+ * Whether the media is formed of independent segments, as defined by the
+ * #EXT-X-INDEPENDENT-SEGMENTS tag.
+ */
+ public final boolean hasIndependentSegments;
+
+ /**
+ * @param baseUri See {@link #baseUri}.
+ * @param tags See {@link #tags}.
+ * @param hasIndependentSegments See {@link #hasIndependentSegments}.
+ */
+ protected HlsPlaylist(String baseUri, List<String> tags, boolean hasIndependentSegments) {
+ this.baseUri = baseUri;
+ this.tags = Collections.unmodifiableList(tags);
+ this.hasIndependentSegments = hasIndependentSegments;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java
new file mode 100644
index 0000000000..5495d28520
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java
@@ -0,0 +1,1007 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Base64;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.UnrecognizedInputFormatException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry.VariantInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Queue;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
+import org.checkerframework.checker.nullness.qual.PolyNull;
+
+/**
+ * HLS playlists parsing logic.
+ */
+public final class HlsPlaylistParser implements ParsingLoadable.Parser<HlsPlaylist> {
+
+ private static final String PLAYLIST_HEADER = "#EXTM3U";
+
+ private static final String TAG_PREFIX = "#EXT";
+
+ private static final String TAG_VERSION = "#EXT-X-VERSION";
+ private static final String TAG_PLAYLIST_TYPE = "#EXT-X-PLAYLIST-TYPE";
+ private static final String TAG_DEFINE = "#EXT-X-DEFINE";
+ private static final String TAG_STREAM_INF = "#EXT-X-STREAM-INF";
+ private static final String TAG_MEDIA = "#EXT-X-MEDIA";
+ private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION";
+ private static final String TAG_DISCONTINUITY = "#EXT-X-DISCONTINUITY";
+ private static final String TAG_DISCONTINUITY_SEQUENCE = "#EXT-X-DISCONTINUITY-SEQUENCE";
+ private static final String TAG_PROGRAM_DATE_TIME = "#EXT-X-PROGRAM-DATE-TIME";
+ private static final String TAG_INIT_SEGMENT = "#EXT-X-MAP";
+ private static final String TAG_INDEPENDENT_SEGMENTS = "#EXT-X-INDEPENDENT-SEGMENTS";
+ private static final String TAG_MEDIA_DURATION = "#EXTINF";
+ private static final String TAG_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE";
+ private static final String TAG_START = "#EXT-X-START";
+ private static final String TAG_ENDLIST = "#EXT-X-ENDLIST";
+ private static final String TAG_KEY = "#EXT-X-KEY";
+ private static final String TAG_SESSION_KEY = "#EXT-X-SESSION-KEY";
+ private static final String TAG_BYTERANGE = "#EXT-X-BYTERANGE";
+ private static final String TAG_GAP = "#EXT-X-GAP";
+
+ private static final String TYPE_AUDIO = "AUDIO";
+ private static final String TYPE_VIDEO = "VIDEO";
+ private static final String TYPE_SUBTITLES = "SUBTITLES";
+ private static final String TYPE_CLOSED_CAPTIONS = "CLOSED-CAPTIONS";
+
+ private static final String METHOD_NONE = "NONE";
+ private static final String METHOD_AES_128 = "AES-128";
+ private static final String METHOD_SAMPLE_AES = "SAMPLE-AES";
+ // Replaced by METHOD_SAMPLE_AES_CTR. Keep for backward compatibility.
+ private static final String METHOD_SAMPLE_AES_CENC = "SAMPLE-AES-CENC";
+ private static final String METHOD_SAMPLE_AES_CTR = "SAMPLE-AES-CTR";
+ private static final String KEYFORMAT_PLAYREADY = "com.microsoft.playready";
+ private static final String KEYFORMAT_IDENTITY = "identity";
+ private static final String KEYFORMAT_WIDEVINE_PSSH_BINARY =
+ "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed";
+ private static final String KEYFORMAT_WIDEVINE_PSSH_JSON = "com.widevine";
+
+ private static final String BOOLEAN_TRUE = "YES";
+ private static final String BOOLEAN_FALSE = "NO";
+
+ private static final String ATTR_CLOSED_CAPTIONS_NONE = "CLOSED-CAPTIONS=NONE";
+
+ private static final Pattern REGEX_AVERAGE_BANDWIDTH =
+ Pattern.compile("AVERAGE-BANDWIDTH=(\\d+)\\b");
+ private static final Pattern REGEX_VIDEO = Pattern.compile("VIDEO=\"(.+?)\"");
+ private static final Pattern REGEX_AUDIO = Pattern.compile("AUDIO=\"(.+?)\"");
+ private static final Pattern REGEX_SUBTITLES = Pattern.compile("SUBTITLES=\"(.+?)\"");
+ private static final Pattern REGEX_CLOSED_CAPTIONS = Pattern.compile("CLOSED-CAPTIONS=\"(.+?)\"");
+ private static final Pattern REGEX_BANDWIDTH = Pattern.compile("[^-]BANDWIDTH=(\\d+)\\b");
+ private static final Pattern REGEX_CHANNELS = Pattern.compile("CHANNELS=\"(.+?)\"");
+ private static final Pattern REGEX_CODECS = Pattern.compile("CODECS=\"(.+?)\"");
+ private static final Pattern REGEX_RESOLUTION = Pattern.compile("RESOLUTION=(\\d+x\\d+)");
+ private static final Pattern REGEX_FRAME_RATE = Pattern.compile("FRAME-RATE=([\\d\\.]+)\\b");
+ private static final Pattern REGEX_TARGET_DURATION = Pattern.compile(TAG_TARGET_DURATION
+ + ":(\\d+)\\b");
+ private static final Pattern REGEX_VERSION = Pattern.compile(TAG_VERSION + ":(\\d+)\\b");
+ private static final Pattern REGEX_PLAYLIST_TYPE = Pattern.compile(TAG_PLAYLIST_TYPE
+ + ":(.+)\\b");
+ private static final Pattern REGEX_MEDIA_SEQUENCE = Pattern.compile(TAG_MEDIA_SEQUENCE
+ + ":(\\d+)\\b");
+ private static final Pattern REGEX_MEDIA_DURATION = Pattern.compile(TAG_MEDIA_DURATION
+ + ":([\\d\\.]+)\\b");
+ private static final Pattern REGEX_MEDIA_TITLE =
+ Pattern.compile(TAG_MEDIA_DURATION + ":[\\d\\.]+\\b,(.+)");
+ private static final Pattern REGEX_TIME_OFFSET = Pattern.compile("TIME-OFFSET=(-?[\\d\\.]+)\\b");
+ private static final Pattern REGEX_BYTERANGE = Pattern.compile(TAG_BYTERANGE
+ + ":(\\d+(?:@\\d+)?)\\b");
+ private static final Pattern REGEX_ATTR_BYTERANGE =
+ Pattern.compile("BYTERANGE=\"(\\d+(?:@\\d+)?)\\b\"");
+ private static final Pattern REGEX_METHOD =
+ Pattern.compile(
+ "METHOD=("
+ + METHOD_NONE
+ + "|"
+ + METHOD_AES_128
+ + "|"
+ + METHOD_SAMPLE_AES
+ + "|"
+ + METHOD_SAMPLE_AES_CENC
+ + "|"
+ + METHOD_SAMPLE_AES_CTR
+ + ")"
+ + "\\s*(?:,|$)");
+ private static final Pattern REGEX_KEYFORMAT = Pattern.compile("KEYFORMAT=\"(.+?)\"");
+ private static final Pattern REGEX_KEYFORMATVERSIONS =
+ Pattern.compile("KEYFORMATVERSIONS=\"(.+?)\"");
+ private static final Pattern REGEX_URI = Pattern.compile("URI=\"(.+?)\"");
+ private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)");
+ private static final Pattern REGEX_TYPE = Pattern.compile("TYPE=(" + TYPE_AUDIO + "|" + TYPE_VIDEO
+ + "|" + TYPE_SUBTITLES + "|" + TYPE_CLOSED_CAPTIONS + ")");
+ private static final Pattern REGEX_LANGUAGE = Pattern.compile("LANGUAGE=\"(.+?)\"");
+ private static final Pattern REGEX_NAME = Pattern.compile("NAME=\"(.+?)\"");
+ private static final Pattern REGEX_GROUP_ID = Pattern.compile("GROUP-ID=\"(.+?)\"");
+ private static final Pattern REGEX_CHARACTERISTICS = Pattern.compile("CHARACTERISTICS=\"(.+?)\"");
+ private static final Pattern REGEX_INSTREAM_ID =
+ Pattern.compile("INSTREAM-ID=\"((?:CC|SERVICE)\\d+)\"");
+ private static final Pattern REGEX_AUTOSELECT = compileBooleanAttrPattern("AUTOSELECT");
+ private static final Pattern REGEX_DEFAULT = compileBooleanAttrPattern("DEFAULT");
+ private static final Pattern REGEX_FORCED = compileBooleanAttrPattern("FORCED");
+ private static final Pattern REGEX_VALUE = Pattern.compile("VALUE=\"(.+?)\"");
+ private static final Pattern REGEX_IMPORT = Pattern.compile("IMPORT=\"(.+?)\"");
+ private static final Pattern REGEX_VARIABLE_REFERENCE =
+ Pattern.compile("\\{\\$([a-zA-Z0-9\\-_]+)\\}");
+
+ private final HlsMasterPlaylist masterPlaylist;
+
+ /**
+ * Creates an instance where media playlists are parsed without inheriting attributes from a
+ * master playlist.
+ */
+ public HlsPlaylistParser() {
+ this(HlsMasterPlaylist.EMPTY);
+ }
+
+ /**
+ * Creates an instance where parsed media playlists inherit attributes from the given master
+ * playlist.
+ *
+ * @param masterPlaylist The master playlist from which media playlists will inherit attributes.
+ */
+ public HlsPlaylistParser(HlsMasterPlaylist masterPlaylist) {
+ this.masterPlaylist = masterPlaylist;
+ }
+
+ @Override
+ public HlsPlaylist parse(Uri uri, InputStream inputStream) throws IOException {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
+ Queue<String> extraLines = new ArrayDeque<>();
+ String line;
+ try {
+ if (!checkPlaylistHeader(reader)) {
+ throw new UnrecognizedInputFormatException("Input does not start with the #EXTM3U header.",
+ uri);
+ }
+ while ((line = reader.readLine()) != null) {
+ line = line.trim();
+ if (line.isEmpty()) {
+ // Do nothing.
+ } else if (line.startsWith(TAG_STREAM_INF)) {
+ extraLines.add(line);
+ return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString());
+ } else if (line.startsWith(TAG_TARGET_DURATION)
+ || line.startsWith(TAG_MEDIA_SEQUENCE)
+ || line.startsWith(TAG_MEDIA_DURATION)
+ || line.startsWith(TAG_KEY)
+ || line.startsWith(TAG_BYTERANGE)
+ || line.equals(TAG_DISCONTINUITY)
+ || line.equals(TAG_DISCONTINUITY_SEQUENCE)
+ || line.equals(TAG_ENDLIST)) {
+ extraLines.add(line);
+ return parseMediaPlaylist(
+ masterPlaylist, new LineIterator(extraLines, reader), uri.toString());
+ } else {
+ extraLines.add(line);
+ }
+ }
+ } finally {
+ Util.closeQuietly(reader);
+ }
+ throw new ParserException("Failed to parse the playlist, could not identify any tags.");
+ }
+
+ private static boolean checkPlaylistHeader(BufferedReader reader) throws IOException {
+ int last = reader.read();
+ if (last == 0xEF) {
+ if (reader.read() != 0xBB || reader.read() != 0xBF) {
+ return false;
+ }
+ // The playlist contains a Byte Order Mark, which gets discarded.
+ last = reader.read();
+ }
+ last = skipIgnorableWhitespace(reader, true, last);
+ int playlistHeaderLength = PLAYLIST_HEADER.length();
+ for (int i = 0; i < playlistHeaderLength; i++) {
+ if (last != PLAYLIST_HEADER.charAt(i)) {
+ return false;
+ }
+ last = reader.read();
+ }
+ last = skipIgnorableWhitespace(reader, false, last);
+ return Util.isLinebreak(last);
+ }
+
+ private static int skipIgnorableWhitespace(BufferedReader reader, boolean skipLinebreaks, int c)
+ throws IOException {
+ while (c != -1 && Character.isWhitespace(c) && (skipLinebreaks || !Util.isLinebreak(c))) {
+ c = reader.read();
+ }
+ return c;
+ }
+
+ private static HlsMasterPlaylist parseMasterPlaylist(LineIterator iterator, String baseUri)
+ throws IOException {
+ HashMap<Uri, ArrayList<VariantInfo>> urlToVariantInfos = new HashMap<>();
+ HashMap<String, String> variableDefinitions = new HashMap<>();
+ ArrayList<Variant> variants = new ArrayList<>();
+ ArrayList<Rendition> videos = new ArrayList<>();
+ ArrayList<Rendition> audios = new ArrayList<>();
+ ArrayList<Rendition> subtitles = new ArrayList<>();
+ ArrayList<Rendition> closedCaptions = new ArrayList<>();
+ ArrayList<String> mediaTags = new ArrayList<>();
+ ArrayList<DrmInitData> sessionKeyDrmInitData = new ArrayList<>();
+ ArrayList<String> tags = new ArrayList<>();
+ Format muxedAudioFormat = null;
+ List<Format> muxedCaptionFormats = null;
+ boolean noClosedCaptions = false;
+ boolean hasIndependentSegmentsTag = false;
+
+ String line;
+ while (iterator.hasNext()) {
+ line = iterator.next();
+
+ if (line.startsWith(TAG_PREFIX)) {
+ // We expose all tags through the playlist.
+ tags.add(line);
+ }
+
+ if (line.startsWith(TAG_DEFINE)) {
+ variableDefinitions.put(
+ /* key= */ parseStringAttr(line, REGEX_NAME, variableDefinitions),
+ /* value= */ parseStringAttr(line, REGEX_VALUE, variableDefinitions));
+ } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) {
+ hasIndependentSegmentsTag = true;
+ } else if (line.startsWith(TAG_MEDIA)) {
+ // Media tags are parsed at the end to include codec information from #EXT-X-STREAM-INF
+ // tags.
+ mediaTags.add(line);
+ } else if (line.startsWith(TAG_SESSION_KEY)) {
+ String keyFormat =
+ parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions);
+ SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions);
+ if (schemeData != null) {
+ String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions);
+ String scheme = parseEncryptionScheme(method);
+ sessionKeyDrmInitData.add(new DrmInitData(scheme, schemeData));
+ }
+ } else if (line.startsWith(TAG_STREAM_INF)) {
+ noClosedCaptions |= line.contains(ATTR_CLOSED_CAPTIONS_NONE);
+ int bitrate = parseIntAttr(line, REGEX_BANDWIDTH);
+ // TODO: Plumb this into Format.
+ int averageBitrate = parseOptionalIntAttr(line, REGEX_AVERAGE_BANDWIDTH, -1);
+ String codecs = parseOptionalStringAttr(line, REGEX_CODECS, variableDefinitions);
+ String resolutionString =
+ parseOptionalStringAttr(line, REGEX_RESOLUTION, variableDefinitions);
+ int width;
+ int height;
+ if (resolutionString != null) {
+ String[] widthAndHeight = resolutionString.split("x");
+ width = Integer.parseInt(widthAndHeight[0]);
+ height = Integer.parseInt(widthAndHeight[1]);
+ if (width <= 0 || height <= 0) {
+ // Resolution string is invalid.
+ width = Format.NO_VALUE;
+ height = Format.NO_VALUE;
+ }
+ } else {
+ width = Format.NO_VALUE;
+ height = Format.NO_VALUE;
+ }
+ float frameRate = Format.NO_VALUE;
+ String frameRateString =
+ parseOptionalStringAttr(line, REGEX_FRAME_RATE, variableDefinitions);
+ if (frameRateString != null) {
+ frameRate = Float.parseFloat(frameRateString);
+ }
+ String videoGroupId = parseOptionalStringAttr(line, REGEX_VIDEO, variableDefinitions);
+ String audioGroupId = parseOptionalStringAttr(line, REGEX_AUDIO, variableDefinitions);
+ String subtitlesGroupId =
+ parseOptionalStringAttr(line, REGEX_SUBTITLES, variableDefinitions);
+ String closedCaptionsGroupId =
+ parseOptionalStringAttr(line, REGEX_CLOSED_CAPTIONS, variableDefinitions);
+ if (!iterator.hasNext()) {
+ throw new ParserException("#EXT-X-STREAM-INF tag must be followed by another line");
+ }
+ line =
+ replaceVariableReferences(
+ iterator.next(), variableDefinitions); // #EXT-X-STREAM-INF's URI.
+ Uri uri = UriUtil.resolveToUri(baseUri, line);
+ Format format =
+ Format.createVideoContainerFormat(
+ /* id= */ Integer.toString(variants.size()),
+ /* label= */ null,
+ /* containerMimeType= */ MimeTypes.APPLICATION_M3U8,
+ /* sampleMimeType= */ null,
+ codecs,
+ /* metadata= */ null,
+ bitrate,
+ width,
+ height,
+ frameRate,
+ /* initializationData= */ null,
+ /* selectionFlags= */ 0,
+ /* roleFlags= */ 0);
+ Variant variant =
+ new Variant(
+ uri, format, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId);
+ variants.add(variant);
+ ArrayList<VariantInfo> variantInfosForUrl = urlToVariantInfos.get(uri);
+ if (variantInfosForUrl == null) {
+ variantInfosForUrl = new ArrayList<>();
+ urlToVariantInfos.put(uri, variantInfosForUrl);
+ }
+ variantInfosForUrl.add(
+ new VariantInfo(
+ bitrate, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId));
+ }
+ }
+
+ // TODO: Don't deduplicate variants by URL.
+ ArrayList<Variant> deduplicatedVariants = new ArrayList<>();
+ HashSet<Uri> urlsInDeduplicatedVariants = new HashSet<>();
+ for (int i = 0; i < variants.size(); i++) {
+ Variant variant = variants.get(i);
+ if (urlsInDeduplicatedVariants.add(variant.url)) {
+ Assertions.checkState(variant.format.metadata == null);
+ HlsTrackMetadataEntry hlsMetadataEntry =
+ new HlsTrackMetadataEntry(
+ /* groupId= */ null,
+ /* name= */ null,
+ Assertions.checkNotNull(urlToVariantInfos.get(variant.url)));
+ deduplicatedVariants.add(
+ variant.copyWithFormat(
+ variant.format.copyWithMetadata(new Metadata(hlsMetadataEntry))));
+ }
+ }
+
+ for (int i = 0; i < mediaTags.size(); i++) {
+ line = mediaTags.get(i);
+ String groupId = parseStringAttr(line, REGEX_GROUP_ID, variableDefinitions);
+ String name = parseStringAttr(line, REGEX_NAME, variableDefinitions);
+ String referenceUri = parseOptionalStringAttr(line, REGEX_URI, variableDefinitions);
+ Uri uri = referenceUri == null ? null : UriUtil.resolveToUri(baseUri, referenceUri);
+ String language = parseOptionalStringAttr(line, REGEX_LANGUAGE, variableDefinitions);
+ @C.SelectionFlags int selectionFlags = parseSelectionFlags(line);
+ @C.RoleFlags int roleFlags = parseRoleFlags(line, variableDefinitions);
+ String formatId = groupId + ":" + name;
+ Format format;
+ Metadata metadata =
+ new Metadata(new HlsTrackMetadataEntry(groupId, name, Collections.emptyList()));
+ switch (parseStringAttr(line, REGEX_TYPE, variableDefinitions)) {
+ case TYPE_VIDEO:
+ Variant variant = getVariantWithVideoGroup(variants, groupId);
+ String codecs = null;
+ int width = Format.NO_VALUE;
+ int height = Format.NO_VALUE;
+ float frameRate = Format.NO_VALUE;
+ if (variant != null) {
+ Format variantFormat = variant.format;
+ codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO);
+ width = variantFormat.width;
+ height = variantFormat.height;
+ frameRate = variantFormat.frameRate;
+ }
+ String sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null;
+ format =
+ Format.createVideoContainerFormat(
+ /* id= */ formatId,
+ /* label= */ name,
+ /* containerMimeType= */ MimeTypes.APPLICATION_M3U8,
+ sampleMimeType,
+ codecs,
+ /* metadata= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ width,
+ height,
+ frameRate,
+ /* initializationData= */ null,
+ selectionFlags,
+ roleFlags)
+ .copyWithMetadata(metadata);
+ if (uri == null) {
+ // TODO: Remove this case and add a Rendition with a null uri to videos.
+ } else {
+ videos.add(new Rendition(uri, format, groupId, name));
+ }
+ break;
+ case TYPE_AUDIO:
+ variant = getVariantWithAudioGroup(variants, groupId);
+ codecs =
+ variant != null
+ ? Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_AUDIO)
+ : null;
+ sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null;
+ String channelsString =
+ parseOptionalStringAttr(line, REGEX_CHANNELS, variableDefinitions);
+ int channelCount = Format.NO_VALUE;
+ if (channelsString != null) {
+ channelCount = Integer.parseInt(Util.splitAtFirst(channelsString, "/")[0]);
+ if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType) && channelsString.endsWith("/JOC")) {
+ sampleMimeType = MimeTypes.AUDIO_E_AC3_JOC;
+ }
+ }
+ format =
+ Format.createAudioContainerFormat(
+ /* id= */ formatId,
+ /* label= */ name,
+ /* containerMimeType= */ MimeTypes.APPLICATION_M3U8,
+ sampleMimeType,
+ codecs,
+ /* metadata= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ channelCount,
+ /* sampleRate= */ Format.NO_VALUE,
+ /* initializationData= */ null,
+ selectionFlags,
+ roleFlags,
+ language);
+ if (uri == null) {
+ // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios.
+ muxedAudioFormat = format;
+ } else {
+ audios.add(new Rendition(uri, format.copyWithMetadata(metadata), groupId, name));
+ }
+ break;
+ case TYPE_SUBTITLES:
+ codecs = null;
+ sampleMimeType = null;
+ variant = getVariantWithSubtitleGroup(variants, groupId);
+ if (variant != null) {
+ codecs = Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_TEXT);
+ sampleMimeType = MimeTypes.getMediaMimeType(codecs);
+ }
+ if (sampleMimeType == null) {
+ sampleMimeType = MimeTypes.TEXT_VTT;
+ }
+ format =
+ Format.createTextContainerFormat(
+ /* id= */ formatId,
+ /* label= */ name,
+ /* containerMimeType= */ MimeTypes.APPLICATION_M3U8,
+ sampleMimeType,
+ codecs,
+ /* bitrate= */ Format.NO_VALUE,
+ selectionFlags,
+ roleFlags,
+ language)
+ .copyWithMetadata(metadata);
+ subtitles.add(new Rendition(uri, format, groupId, name));
+ break;
+ case TYPE_CLOSED_CAPTIONS:
+ String instreamId = parseStringAttr(line, REGEX_INSTREAM_ID, variableDefinitions);
+ String mimeType;
+ int accessibilityChannel;
+ if (instreamId.startsWith("CC")) {
+ mimeType = MimeTypes.APPLICATION_CEA608;
+ accessibilityChannel = Integer.parseInt(instreamId.substring(2));
+ } else /* starts with SERVICE */ {
+ mimeType = MimeTypes.APPLICATION_CEA708;
+ accessibilityChannel = Integer.parseInt(instreamId.substring(7));
+ }
+ if (muxedCaptionFormats == null) {
+ muxedCaptionFormats = new ArrayList<>();
+ }
+ muxedCaptionFormats.add(
+ Format.createTextContainerFormat(
+ /* id= */ formatId,
+ /* label= */ name,
+ /* containerMimeType= */ null,
+ /* sampleMimeType= */ mimeType,
+ /* codecs= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ selectionFlags,
+ roleFlags,
+ language,
+ accessibilityChannel));
+ // TODO: Remove muxedCaptionFormats and add a Rendition with a null uri to closedCaptions.
+ break;
+ default:
+ // Do nothing.
+ break;
+ }
+ }
+
+ if (noClosedCaptions) {
+ muxedCaptionFormats = Collections.emptyList();
+ }
+
+ return new HlsMasterPlaylist(
+ baseUri,
+ tags,
+ deduplicatedVariants,
+ videos,
+ audios,
+ subtitles,
+ closedCaptions,
+ muxedAudioFormat,
+ muxedCaptionFormats,
+ hasIndependentSegmentsTag,
+ variableDefinitions,
+ sessionKeyDrmInitData);
+ }
+
+ @Nullable
+ private static Variant getVariantWithAudioGroup(ArrayList<Variant> variants, String groupId) {
+ for (int i = 0; i < variants.size(); i++) {
+ Variant variant = variants.get(i);
+ if (groupId.equals(variant.audioGroupId)) {
+ return variant;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ private static Variant getVariantWithVideoGroup(ArrayList<Variant> variants, String groupId) {
+ for (int i = 0; i < variants.size(); i++) {
+ Variant variant = variants.get(i);
+ if (groupId.equals(variant.videoGroupId)) {
+ return variant;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ private static Variant getVariantWithSubtitleGroup(ArrayList<Variant> variants, String groupId) {
+ for (int i = 0; i < variants.size(); i++) {
+ Variant variant = variants.get(i);
+ if (groupId.equals(variant.subtitleGroupId)) {
+ return variant;
+ }
+ }
+ return null;
+ }
+
+ private static HlsMediaPlaylist parseMediaPlaylist(
+ HlsMasterPlaylist masterPlaylist, LineIterator iterator, String baseUri) throws IOException {
+ @HlsMediaPlaylist.PlaylistType int playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN;
+ long startOffsetUs = C.TIME_UNSET;
+ long mediaSequence = 0;
+ int version = 1; // Default version == 1.
+ long targetDurationUs = C.TIME_UNSET;
+ boolean hasIndependentSegmentsTag = masterPlaylist.hasIndependentSegments;
+ boolean hasEndTag = false;
+ Segment initializationSegment = null;
+ HashMap<String, String> variableDefinitions = new HashMap<>();
+ List<Segment> segments = new ArrayList<>();
+ List<String> tags = new ArrayList<>();
+
+ long segmentDurationUs = 0;
+ String segmentTitle = "";
+ boolean hasDiscontinuitySequence = false;
+ int playlistDiscontinuitySequence = 0;
+ int relativeDiscontinuitySequence = 0;
+ long playlistStartTimeUs = 0;
+ long segmentStartTimeUs = 0;
+ long segmentByteRangeOffset = 0;
+ long segmentByteRangeLength = C.LENGTH_UNSET;
+ long segmentMediaSequence = 0;
+ boolean hasGapTag = false;
+
+ DrmInitData playlistProtectionSchemes = null;
+ String fullSegmentEncryptionKeyUri = null;
+ String fullSegmentEncryptionIV = null;
+ TreeMap<String, SchemeData> currentSchemeDatas = new TreeMap<>();
+ String encryptionScheme = null;
+ DrmInitData cachedDrmInitData = null;
+
+ String line;
+ while (iterator.hasNext()) {
+ line = iterator.next();
+
+ if (line.startsWith(TAG_PREFIX)) {
+ // We expose all tags through the playlist.
+ tags.add(line);
+ }
+
+ if (line.startsWith(TAG_PLAYLIST_TYPE)) {
+ String playlistTypeString = parseStringAttr(line, REGEX_PLAYLIST_TYPE, variableDefinitions);
+ if ("VOD".equals(playlistTypeString)) {
+ playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_VOD;
+ } else if ("EVENT".equals(playlistTypeString)) {
+ playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_EVENT;
+ }
+ } else if (line.startsWith(TAG_START)) {
+ startOffsetUs = (long) (parseDoubleAttr(line, REGEX_TIME_OFFSET) * C.MICROS_PER_SECOND);
+ } else if (line.startsWith(TAG_INIT_SEGMENT)) {
+ String uri = parseStringAttr(line, REGEX_URI, variableDefinitions);
+ String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions);
+ if (byteRange != null) {
+ String[] splitByteRange = byteRange.split("@");
+ segmentByteRangeLength = Long.parseLong(splitByteRange[0]);
+ if (splitByteRange.length > 1) {
+ segmentByteRangeOffset = Long.parseLong(splitByteRange[1]);
+ }
+ }
+ if (fullSegmentEncryptionKeyUri != null && fullSegmentEncryptionIV == null) {
+ // See RFC 8216, Section 4.3.2.5.
+ throw new ParserException(
+ "The encryption IV attribute must be present when an initialization segment is "
+ + "encrypted with METHOD=AES-128.");
+ }
+ initializationSegment =
+ new Segment(
+ uri,
+ segmentByteRangeOffset,
+ segmentByteRangeLength,
+ fullSegmentEncryptionKeyUri,
+ fullSegmentEncryptionIV);
+ segmentByteRangeOffset = 0;
+ segmentByteRangeLength = C.LENGTH_UNSET;
+ } else if (line.startsWith(TAG_TARGET_DURATION)) {
+ targetDurationUs = parseIntAttr(line, REGEX_TARGET_DURATION) * C.MICROS_PER_SECOND;
+ } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) {
+ mediaSequence = parseLongAttr(line, REGEX_MEDIA_SEQUENCE);
+ segmentMediaSequence = mediaSequence;
+ } else if (line.startsWith(TAG_VERSION)) {
+ version = parseIntAttr(line, REGEX_VERSION);
+ } else if (line.startsWith(TAG_DEFINE)) {
+ String importName = parseOptionalStringAttr(line, REGEX_IMPORT, variableDefinitions);
+ if (importName != null) {
+ String value = masterPlaylist.variableDefinitions.get(importName);
+ if (value != null) {
+ variableDefinitions.put(importName, value);
+ } else {
+ // The master playlist does not declare the imported variable. Ignore.
+ }
+ } else {
+ variableDefinitions.put(
+ parseStringAttr(line, REGEX_NAME, variableDefinitions),
+ parseStringAttr(line, REGEX_VALUE, variableDefinitions));
+ }
+ } else if (line.startsWith(TAG_MEDIA_DURATION)) {
+ segmentDurationUs =
+ (long) (parseDoubleAttr(line, REGEX_MEDIA_DURATION) * C.MICROS_PER_SECOND);
+ segmentTitle = parseOptionalStringAttr(line, REGEX_MEDIA_TITLE, "", variableDefinitions);
+ } else if (line.startsWith(TAG_KEY)) {
+ String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions);
+ String keyFormat =
+ parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions);
+ fullSegmentEncryptionKeyUri = null;
+ fullSegmentEncryptionIV = null;
+ if (METHOD_NONE.equals(method)) {
+ currentSchemeDatas.clear();
+ cachedDrmInitData = null;
+ } else /* !METHOD_NONE.equals(method) */ {
+ fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions);
+ if (KEYFORMAT_IDENTITY.equals(keyFormat)) {
+ if (METHOD_AES_128.equals(method)) {
+ // The segment is fully encrypted using an identity key.
+ fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions);
+ } else {
+ // Do nothing. Samples are encrypted using an identity key, but this is not supported.
+ // Hopefully, a traditional DRM alternative is also provided.
+ }
+ } else {
+ if (encryptionScheme == null) {
+ encryptionScheme = parseEncryptionScheme(method);
+ }
+ SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions);
+ if (schemeData != null) {
+ cachedDrmInitData = null;
+ currentSchemeDatas.put(keyFormat, schemeData);
+ }
+ }
+ }
+ } else if (line.startsWith(TAG_BYTERANGE)) {
+ String byteRange = parseStringAttr(line, REGEX_BYTERANGE, variableDefinitions);
+ String[] splitByteRange = byteRange.split("@");
+ segmentByteRangeLength = Long.parseLong(splitByteRange[0]);
+ if (splitByteRange.length > 1) {
+ segmentByteRangeOffset = Long.parseLong(splitByteRange[1]);
+ }
+ } else if (line.startsWith(TAG_DISCONTINUITY_SEQUENCE)) {
+ hasDiscontinuitySequence = true;
+ playlistDiscontinuitySequence = Integer.parseInt(line.substring(line.indexOf(':') + 1));
+ } else if (line.equals(TAG_DISCONTINUITY)) {
+ relativeDiscontinuitySequence++;
+ } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) {
+ if (playlistStartTimeUs == 0) {
+ long programDatetimeUs =
+ C.msToUs(Util.parseXsDateTime(line.substring(line.indexOf(':') + 1)));
+ playlistStartTimeUs = programDatetimeUs - segmentStartTimeUs;
+ }
+ } else if (line.equals(TAG_GAP)) {
+ hasGapTag = true;
+ } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) {
+ hasIndependentSegmentsTag = true;
+ } else if (line.equals(TAG_ENDLIST)) {
+ hasEndTag = true;
+ } else if (!line.startsWith("#")) {
+ String segmentEncryptionIV;
+ if (fullSegmentEncryptionKeyUri == null) {
+ segmentEncryptionIV = null;
+ } else if (fullSegmentEncryptionIV != null) {
+ segmentEncryptionIV = fullSegmentEncryptionIV;
+ } else {
+ segmentEncryptionIV = Long.toHexString(segmentMediaSequence);
+ }
+
+ segmentMediaSequence++;
+ if (segmentByteRangeLength == C.LENGTH_UNSET) {
+ segmentByteRangeOffset = 0;
+ }
+
+ if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) {
+ SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]);
+ cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas);
+ if (playlistProtectionSchemes == null) {
+ SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length];
+ for (int i = 0; i < schemeDatas.length; i++) {
+ playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null);
+ }
+ playlistProtectionSchemes = new DrmInitData(encryptionScheme, playlistSchemeDatas);
+ }
+ }
+
+ segments.add(
+ new Segment(
+ replaceVariableReferences(line, variableDefinitions),
+ initializationSegment,
+ segmentTitle,
+ segmentDurationUs,
+ relativeDiscontinuitySequence,
+ segmentStartTimeUs,
+ cachedDrmInitData,
+ fullSegmentEncryptionKeyUri,
+ segmentEncryptionIV,
+ segmentByteRangeOffset,
+ segmentByteRangeLength,
+ hasGapTag));
+ segmentStartTimeUs += segmentDurationUs;
+ segmentDurationUs = 0;
+ segmentTitle = "";
+ if (segmentByteRangeLength != C.LENGTH_UNSET) {
+ segmentByteRangeOffset += segmentByteRangeLength;
+ }
+ segmentByteRangeLength = C.LENGTH_UNSET;
+ hasGapTag = false;
+ }
+ }
+ return new HlsMediaPlaylist(
+ playlistType,
+ baseUri,
+ tags,
+ startOffsetUs,
+ playlistStartTimeUs,
+ hasDiscontinuitySequence,
+ playlistDiscontinuitySequence,
+ mediaSequence,
+ version,
+ targetDurationUs,
+ hasIndependentSegmentsTag,
+ hasEndTag,
+ /* hasProgramDateTime= */ playlistStartTimeUs != 0,
+ playlistProtectionSchemes,
+ segments);
+ }
+
+ @C.SelectionFlags
+ private static int parseSelectionFlags(String line) {
+ int flags = 0;
+ if (parseOptionalBooleanAttribute(line, REGEX_DEFAULT, false)) {
+ flags |= C.SELECTION_FLAG_DEFAULT;
+ }
+ if (parseOptionalBooleanAttribute(line, REGEX_FORCED, false)) {
+ flags |= C.SELECTION_FLAG_FORCED;
+ }
+ if (parseOptionalBooleanAttribute(line, REGEX_AUTOSELECT, false)) {
+ flags |= C.SELECTION_FLAG_AUTOSELECT;
+ }
+ return flags;
+ }
+
+ @C.RoleFlags
+ private static int parseRoleFlags(String line, Map<String, String> variableDefinitions) {
+ String concatenatedCharacteristics =
+ parseOptionalStringAttr(line, REGEX_CHARACTERISTICS, variableDefinitions);
+ if (TextUtils.isEmpty(concatenatedCharacteristics)) {
+ return 0;
+ }
+ String[] characteristics = Util.split(concatenatedCharacteristics, ",");
+ @C.RoleFlags int roleFlags = 0;
+ if (Util.contains(characteristics, "public.accessibility.describes-video")) {
+ roleFlags |= C.ROLE_FLAG_DESCRIBES_VIDEO;
+ }
+ if (Util.contains(characteristics, "public.accessibility.transcribes-spoken-dialog")) {
+ roleFlags |= C.ROLE_FLAG_TRANSCRIBES_DIALOG;
+ }
+ if (Util.contains(characteristics, "public.accessibility.describes-music-and-sound")) {
+ roleFlags |= C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND;
+ }
+ if (Util.contains(characteristics, "public.easy-to-read")) {
+ roleFlags |= C.ROLE_FLAG_EASY_TO_READ;
+ }
+ return roleFlags;
+ }
+
+ @Nullable
+ private static SchemeData parseDrmSchemeData(
+ String line, String keyFormat, Map<String, String> variableDefinitions)
+ throws ParserException {
+ String keyFormatVersions =
+ parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions);
+ if (KEYFORMAT_WIDEVINE_PSSH_BINARY.equals(keyFormat)) {
+ String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions);
+ return new SchemeData(
+ C.WIDEVINE_UUID,
+ MimeTypes.VIDEO_MP4,
+ Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT));
+ } else if (KEYFORMAT_WIDEVINE_PSSH_JSON.equals(keyFormat)) {
+ return new SchemeData(C.WIDEVINE_UUID, "hls", Util.getUtf8Bytes(line));
+ } else if (KEYFORMAT_PLAYREADY.equals(keyFormat) && "1".equals(keyFormatVersions)) {
+ String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions);
+ byte[] data = Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT);
+ byte[] psshData = PsshAtomUtil.buildPsshAtom(C.PLAYREADY_UUID, data);
+ return new SchemeData(C.PLAYREADY_UUID, MimeTypes.VIDEO_MP4, psshData);
+ }
+ return null;
+ }
+
+ private static String parseEncryptionScheme(String method) {
+ return METHOD_SAMPLE_AES_CENC.equals(method) || METHOD_SAMPLE_AES_CTR.equals(method)
+ ? C.CENC_TYPE_cenc
+ : C.CENC_TYPE_cbcs;
+ }
+
+ private static int parseIntAttr(String line, Pattern pattern) throws ParserException {
+ return Integer.parseInt(parseStringAttr(line, pattern, Collections.emptyMap()));
+ }
+
+ private static int parseOptionalIntAttr(String line, Pattern pattern, int defaultValue) {
+ Matcher matcher = pattern.matcher(line);
+ if (matcher.find()) {
+ return Integer.parseInt(matcher.group(1));
+ }
+ return defaultValue;
+ }
+
+ private static long parseLongAttr(String line, Pattern pattern) throws ParserException {
+ return Long.parseLong(parseStringAttr(line, pattern, Collections.emptyMap()));
+ }
+
+ private static double parseDoubleAttr(String line, Pattern pattern) throws ParserException {
+ return Double.parseDouble(parseStringAttr(line, pattern, Collections.emptyMap()));
+ }
+
+ private static String parseStringAttr(
+ String line, Pattern pattern, Map<String, String> variableDefinitions)
+ throws ParserException {
+ String value = parseOptionalStringAttr(line, pattern, variableDefinitions);
+ if (value != null) {
+ return value;
+ } else {
+ throw new ParserException("Couldn't match " + pattern.pattern() + " in " + line);
+ }
+ }
+
+ private static @Nullable String parseOptionalStringAttr(
+ String line, Pattern pattern, Map<String, String> variableDefinitions) {
+ return parseOptionalStringAttr(line, pattern, null, variableDefinitions);
+ }
+
+ private static @PolyNull String parseOptionalStringAttr(
+ String line,
+ Pattern pattern,
+ @PolyNull String defaultValue,
+ Map<String, String> variableDefinitions) {
+ Matcher matcher = pattern.matcher(line);
+ String value = matcher.find() ? matcher.group(1) : defaultValue;
+ return variableDefinitions.isEmpty() || value == null
+ ? value
+ : replaceVariableReferences(value, variableDefinitions);
+ }
+
+ private static String replaceVariableReferences(
+ String string, Map<String, String> variableDefinitions) {
+ Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string);
+ // TODO: Replace StringBuffer with StringBuilder once Java 9 is available.
+ StringBuffer stringWithReplacements = new StringBuffer();
+ while (matcher.find()) {
+ String groupName = matcher.group(1);
+ if (variableDefinitions.containsKey(groupName)) {
+ matcher.appendReplacement(
+ stringWithReplacements, Matcher.quoteReplacement(variableDefinitions.get(groupName)));
+ } else {
+ // The variable is not defined. The value is ignored.
+ }
+ }
+ matcher.appendTail(stringWithReplacements);
+ return stringWithReplacements.toString();
+ }
+
+ private static boolean parseOptionalBooleanAttribute(
+ String line, Pattern pattern, boolean defaultValue) {
+ Matcher matcher = pattern.matcher(line);
+ if (matcher.find()) {
+ return matcher.group(1).equals(BOOLEAN_TRUE);
+ }
+ return defaultValue;
+ }
+
+ private static Pattern compileBooleanAttrPattern(String attribute) {
+ return Pattern.compile(attribute + "=(" + BOOLEAN_FALSE + "|" + BOOLEAN_TRUE + ")");
+ }
+
+ private static class LineIterator {
+
+ private final BufferedReader reader;
+ private final Queue<String> extraLines;
+
+ @Nullable private String next;
+
+ public LineIterator(Queue<String> extraLines, BufferedReader reader) {
+ this.extraLines = extraLines;
+ this.reader = reader;
+ }
+
+ @EnsuresNonNullIf(expression = "next", result = true)
+ public boolean hasNext() throws IOException {
+ if (next != null) {
+ return true;
+ }
+ if (!extraLines.isEmpty()) {
+ next = Assertions.checkNotNull(extraLines.poll());
+ return true;
+ }
+ while ((next = reader.readLine()) != null) {
+ next = next.trim();
+ if (!next.isEmpty()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Return the next line, or throw {@link NoSuchElementException} if none. */
+ public String next() throws IOException {
+ if (hasNext()) {
+ String result = next;
+ next = null;
+ return result;
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java
new file mode 100644
index 0000000000..deb1daf8a7
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable;
+
+/** Factory for {@link HlsPlaylist} parsers. */
+public interface HlsPlaylistParserFactory {
+
+ /**
+ * Returns a stand-alone playlist parser. Playlists parsed by the returned parser do not inherit
+ * any attributes from other playlists.
+ */
+ ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser();
+
+ /**
+ * Returns a playlist parser for playlists that were referenced by the given {@link
+ * HlsMasterPlaylist}. Returned {@link HlsMediaPlaylist} instances may inherit attributes from
+ * {@code masterPlaylist}.
+ *
+ * @param masterPlaylist The master playlist that referenced any parsed media playlists.
+ * @return A parser for HLS playlists.
+ */
+ ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(HlsMasterPlaylist masterPlaylist);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java
new file mode 100644
index 0000000000..69f8cb02c9
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
+import java.io.IOException;
+
+/**
+ * Tracks playlists associated to an HLS stream and provides snapshots.
+ *
+ * <p>The playlist tracker is responsible for exposing the seeking window, which is defined by the
+ * segments that one of the playlists exposes. This playlist is called primary and needs to be
+ * periodically refreshed in the case of live streams. Note that the primary playlist is one of the
+ * media playlists while the master playlist is an optional kind of playlist defined by the HLS
+ * specification (RFC 8216).
+ *
+ * <p>Playlist loads might encounter errors. The tracker may choose to blacklist them to ensure a
+ * primary playlist is always available.
+ */
+public interface HlsPlaylistTracker {
+
+ /** Factory for {@link HlsPlaylistTracker} instances. */
+ interface Factory {
+
+ /**
+ * Creates a new tracker instance.
+ *
+ * @param dataSourceFactory The {@link HlsDataSourceFactory} to use for playlist loading.
+ * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy} for playlist load errors.
+ * @param playlistParserFactory The {@link HlsPlaylistParserFactory} for playlist parsing.
+ */
+ HlsPlaylistTracker createTracker(
+ HlsDataSourceFactory dataSourceFactory,
+ LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+ HlsPlaylistParserFactory playlistParserFactory);
+ }
+
+ /** Listener for primary playlist changes. */
+ interface PrimaryPlaylistListener {
+
+ /**
+ * Called when the primary playlist changes.
+ *
+ * @param mediaPlaylist The primary playlist new snapshot.
+ */
+ void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist);
+ }
+
+ /** Called on playlist loading events. */
+ interface PlaylistEventListener {
+
+ /**
+ * Called a playlist changes.
+ */
+ void onPlaylistChanged();
+
+ /**
+ * Called if an error is encountered while loading a playlist.
+ *
+ * @param url The loaded url that caused the error.
+ * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or
+ * {@link C#TIME_UNSET} if the playlist should not be blacklisted.
+ * @return True if blacklisting did not encounter errors. False otherwise.
+ */
+ boolean onPlaylistError(Uri url, long blacklistDurationMs);
+ }
+
+ /** Thrown when a playlist is considered to be stuck due to a server side error. */
+ final class PlaylistStuckException extends IOException {
+
+ /** The url of the stuck playlist. */
+ public final Uri url;
+
+ /**
+ * Creates an instance.
+ *
+ * @param url See {@link #url}.
+ */
+ public PlaylistStuckException(Uri url) {
+ this.url = url;
+ }
+ }
+
+ /** Thrown when the media sequence of a new snapshot indicates the server has reset. */
+ final class PlaylistResetException extends IOException {
+
+ /** The url of the reset playlist. */
+ public final Uri url;
+
+ /**
+ * Creates an instance.
+ *
+ * @param url See {@link #url}.
+ */
+ public PlaylistResetException(Uri url) {
+ this.url = url;
+ }
+ }
+
+ /**
+ * Starts the playlist tracker.
+ *
+ * <p>Must be called from the playback thread. A tracker may be restarted after a {@link #stop()}
+ * call.
+ *
+ * @param initialPlaylistUri Uri of the HLS stream. Can point to a media playlist or a master
+ * playlist.
+ * @param eventDispatcher A dispatcher to notify of events.
+ * @param listener A callback for the primary playlist change events.
+ */
+ void start(
+ Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener listener);
+
+ /**
+ * Stops the playlist tracker and releases any acquired resources.
+ *
+ * <p>Must be called once per {@link #start} call.
+ */
+ void stop();
+
+ /**
+ * Registers a listener to receive events from the playlist tracker.
+ *
+ * @param listener The listener.
+ */
+ void addListener(PlaylistEventListener listener);
+
+ /**
+ * Unregisters a listener.
+ *
+ * @param listener The listener to unregister.
+ */
+ void removeListener(PlaylistEventListener listener);
+
+ /**
+ * Returns the master playlist.
+ *
+ * <p>If the uri passed to {@link #start} points to a media playlist, an {@link HlsMasterPlaylist}
+ * with a single variant for said media playlist is returned.
+ *
+ * @return The master playlist. Null if the initial playlist has yet to be loaded.
+ */
+ @Nullable
+ HlsMasterPlaylist getMasterPlaylist();
+
+ /**
+ * Returns the most recent snapshot available of the playlist referenced by the provided {@link
+ * Uri}.
+ *
+ * @param url The {@link Uri} corresponding to the requested media playlist.
+ * @param isForPlayback Whether the caller might use the snapshot to request media segments for
+ * playback. If true, the primary playlist may be updated to the one requested.
+ * @return The most recent snapshot of the playlist referenced by the provided {@link Uri}. May be
+ * null if no snapshot has been loaded yet.
+ */
+ @Nullable
+ HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback);
+
+ /**
+ * Returns the start time of the first loaded primary playlist, or {@link C#TIME_UNSET} if no
+ * media playlist has been loaded.
+ */
+ long getInitialStartTimeUs();
+
+ /**
+ * Returns whether the snapshot of the playlist referenced by the provided {@link Uri} is valid,
+ * meaning all the segments referenced by the playlist are expected to be available. If the
+ * playlist is not valid then some of the segments may no longer be available.
+ *
+ * @param url The {@link Uri}.
+ * @return Whether the snapshot of the playlist referenced by the provided {@link Uri} is valid.
+ */
+ boolean isSnapshotValid(Uri url);
+
+ /**
+ * If the tracker is having trouble refreshing the master playlist or the primary playlist, this
+ * method throws the underlying error. Otherwise, does nothing.
+ *
+ * @throws IOException The underlying error.
+ */
+ void maybeThrowPrimaryPlaylistRefreshError() throws IOException;
+
+ /**
+ * If the playlist is having trouble refreshing the playlist referenced by the given {@link Uri},
+ * this method throws the underlying error.
+ *
+ * @param url The {@link Uri}.
+ * @throws IOException The underyling error.
+ */
+ void maybeThrowPlaylistRefreshError(Uri url) throws IOException;
+
+ /**
+ * Requests a playlist refresh and whitelists it.
+ *
+ * <p>The playlist tracker may choose the delay the playlist refresh. The request is discarded if
+ * a refresh was already pending.
+ *
+ * @param url The {@link Uri} of the playlist to be refreshed.
+ */
+ void refreshPlaylist(Uri url);
+
+ /**
+ * Returns whether the tracked playlists describe a live stream.
+ *
+ * @return True if the content is live. False otherwise.
+ */
+ boolean isLive();
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java
new file mode 100644
index 0000000000..be9f862644
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;