diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls')
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; |