Initial hlsmplayer application

This commit is contained in:
root
2026-09-27 17:13:02 +08:00
commit be5a40bfe3
1214 changed files with 315753 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
plugins { id("com.android.library") }
android {
namespace = "androidx.media3.exoplayer.hls"
compileSdk = 36
defaultConfig { minSdk = 23 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
dependencies {
api(project(":lib-exoplayer"))
implementation(libs.androidx.annotation)
implementation(libs.androidx.concurrent.futures)
compileOnly(libs.checkerframework.qual)
compileOnly(libs.errorprone.annotations)
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest>
<uses-sdk />
</manifest>
@@ -0,0 +1,131 @@
/*
* 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 androidx.media3.exoplayer.hls;
import static com.google.common.base.Preconditions.checkNotNull;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSourceInputStream;
import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.TransferListener;
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) {
checkNotNull(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 length) throws IOException {
checkNotNull(cipherInputStream);
int bytesRead = cipherInputStream.read(buffer, offset, length);
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");
}
}
@@ -0,0 +1,111 @@
/*
* Copyright 2024 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 androidx.media3.exoplayer.hls;
import static com.google.common.io.ByteStreams.toByteArray;
import static java.nio.charset.StandardCharsets.UTF_8;
import android.net.Uri;
import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.media3.common.AdPlaybackState.SkipInfo;
import androidx.media3.common.C;
import androidx.media3.common.ParserException;
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.Asset;
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.io.InputStream;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/** Parses a X-ASSET-LIST JSON object. */
/* package */ final class AssetListParser
implements ParsingLoadable.Parser<Pair<AssetList, JSONObject>> {
/** The asset name of the assets array in a X-ASSET-LIST JSON object. */
private static final String ASSET_LIST_JSON_NAME_ASSET_ARRAY = "ASSETS";
/** The asset URI name in a X-ASSET-LIST JSON object. */
private static final String ASSET_LIST_JSON_NAME_URI = "URI";
/** The asset duration name in a X-ASSET-LIST JSON object. */
private static final String ASSET_LIST_JSON_NAME_DURATION = "DURATION";
/** The skip control field name in a X-ASSET-LIST JSON object. */
private static final String ASSET_LIST_JSON_NAME_SKIP_CONTROL = "SKIP-CONTROL";
/** The offset field name in a SKIP-CONTROL JSON object. */
private static final String ASSET_LIST_JSON_NAME_OFFSET = "OFFSET";
/** The label ID field name in a SKIP-CONTROL JSON object. */
private static final String ASSET_LIST_JSON_NAME_LABEL_ID = "LABEL-ID";
@Override
public Pair<AssetList, JSONObject> parse(Uri uri, InputStream inputStream) throws IOException {
try {
JSONObject jsonObject = new JSONObject(new String(toByteArray(inputStream), UTF_8));
return new Pair<>(getAssetListFromRawJson(jsonObject), jsonObject);
} catch (IOException | JSONException e) {
throw ParserException.createForMalformedManifest(e.getMessage(), e);
}
}
private static AssetList getAssetListFromRawJson(JSONObject jsonObject) throws JSONException {
if (!jsonObject.has(ASSET_LIST_JSON_NAME_ASSET_ARRAY)) {
throw new JSONException("missing " + ASSET_LIST_JSON_NAME_ASSET_ARRAY + " attribute");
}
ImmutableList.Builder<Asset> assets = new ImmutableList.Builder<>();
@Nullable SkipInfo skipInfo = null;
JSONArray jsonArray = jsonObject.getJSONArray(ASSET_LIST_JSON_NAME_ASSET_ARRAY);
for (int i = 0; i < jsonArray.length(); i++) {
JSONObject assetObject = jsonArray.getJSONObject(i);
if (!assetObject.has(ASSET_LIST_JSON_NAME_URI)) {
throw new JSONException("missing " + ASSET_LIST_JSON_NAME_URI + " attribute");
}
if (!assetObject.has(ASSET_LIST_JSON_NAME_DURATION)) {
throw new JSONException("missing " + ASSET_LIST_JSON_NAME_DURATION + " attribute");
}
Uri assetUri = Uri.parse(assetObject.getString(ASSET_LIST_JSON_NAME_URI));
long durationUs =
(long) (assetObject.getDouble(ASSET_LIST_JSON_NAME_DURATION) * C.MICROS_PER_SECOND);
assets.add(new Asset(assetUri, durationUs));
}
if (jsonObject.has(ASSET_LIST_JSON_NAME_SKIP_CONTROL)) {
JSONObject skipControlObject = jsonObject.getJSONObject(ASSET_LIST_JSON_NAME_SKIP_CONTROL);
long offsetUs = 0;
if (skipControlObject.has(ASSET_LIST_JSON_NAME_OFFSET)) {
offsetUs =
(long) (skipControlObject.getDouble(ASSET_LIST_JSON_NAME_OFFSET) * C.MICROS_PER_SECOND);
}
long durationUs = C.TIME_UNSET;
if (skipControlObject.has(ASSET_LIST_JSON_NAME_DURATION)) {
durationUs =
(long)
(skipControlObject.getDouble(ASSET_LIST_JSON_NAME_DURATION) * C.MICROS_PER_SECOND);
}
@Nullable String labelId = null;
if (skipControlObject.has(ASSET_LIST_JSON_NAME_LABEL_ID)) {
labelId = skipControlObject.getString(ASSET_LIST_JSON_NAME_LABEL_ID);
}
skipInfo = new SkipInfo(offsetUs, durationUs, labelId);
}
return new AssetList(assets.build(), skipInfo);
}
}
@@ -0,0 +1,162 @@
/*
* Copyright 2020 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 androidx.media3.exoplayer.hls;
import static com.google.common.base.Preconditions.checkState;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.Format;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.mp3.Mp3Extractor;
import androidx.media3.extractor.mp4.FragmentedMp4Extractor;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.media3.extractor.ts.Ac3Extractor;
import androidx.media3.extractor.ts.Ac4Extractor;
import androidx.media3.extractor.ts.AdtsExtractor;
import androidx.media3.extractor.ts.TsExtractor;
import java.io.IOException;
/**
* {@link HlsMediaChunkExtractor} implementation that uses ExoPlayer app-bundled {@link Extractor
* Extractors}.
*/
@UnstableApi
public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtractor {
private static final PositionHolder POSITION_HOLDER = new PositionHolder();
@VisibleForTesting /* package */ final Extractor extractor;
private final Format multivariantPlaylistFormat;
private final TimestampAdjuster timestampAdjuster;
private final SubtitleParser.Factory subtitleParserFactory;
private final boolean parseSubtitlesDuringExtraction;
/**
* Creates a new instance.
*
* @param extractor The underlying {@link Extractor}.
* @param multivariantPlaylistFormat The {@link Format} obtained from the multivariant playlist.
* @param timestampAdjuster A {@link TimestampAdjuster} to adjust sample timestamps.
*/
public BundledHlsMediaChunkExtractor(
Extractor extractor, Format multivariantPlaylistFormat, TimestampAdjuster timestampAdjuster) {
this(
extractor,
multivariantPlaylistFormat,
timestampAdjuster,
SubtitleParser.Factory.UNSUPPORTED,
/* parseSubtitlesDuringExtraction= */ false);
}
/**
* Creates a new instance.
*
* @param extractor The underlying {@link Extractor}.
* @param multivariantPlaylistFormat The {@link Format} obtained from the multivariant playlist.
* @param timestampAdjuster A {@link TimestampAdjuster} to adjust sample timestamps.
* @param subtitleParserFactory A {@link SubtitleParser.Factory} to be used with WebVTT subtitles.
* If the value is null, subtitles will be parsed during decoding, otherwise - during
* extraction. Decoding will only work if this subtitleParserFactory supports the provided
* multivariantPlaylistFormat.
*/
// TODO(b/289983417): Once the subtitle-parsing-during-extraction is the only available flow, make
// this constructor public and remove parseSubtitlesDuringExtraction parameter
/* package */ BundledHlsMediaChunkExtractor(
Extractor extractor,
Format multivariantPlaylistFormat,
TimestampAdjuster timestampAdjuster,
SubtitleParser.Factory subtitleParserFactory,
boolean parseSubtitlesDuringExtraction) {
this.extractor = extractor;
this.multivariantPlaylistFormat = multivariantPlaylistFormat;
this.timestampAdjuster = timestampAdjuster;
this.subtitleParserFactory = subtitleParserFactory;
this.parseSubtitlesDuringExtraction = parseSubtitlesDuringExtraction;
}
@Override
public void init(ExtractorOutput extractorOutput) {
extractor.init(extractorOutput);
}
@Override
public boolean read(ExtractorInput extractorInput) throws IOException {
return extractor.read(extractorInput, POSITION_HOLDER) == Extractor.RESULT_CONTINUE;
}
@Override
public boolean isPackedAudioExtractor() {
Extractor underlyingExtractor = extractor.getUnderlyingImplementation();
return underlyingExtractor instanceof AdtsExtractor
|| underlyingExtractor instanceof Ac3Extractor
|| underlyingExtractor instanceof Ac4Extractor
|| underlyingExtractor instanceof Mp3Extractor;
}
@Override
public boolean isReusable() {
Extractor underlyingExtractor = extractor.getUnderlyingImplementation();
return underlyingExtractor instanceof TsExtractor
|| underlyingExtractor instanceof FragmentedMp4Extractor;
}
@Override
public HlsMediaChunkExtractor recreate() {
checkState(!isReusable());
checkState(
extractor.getUnderlyingImplementation() == extractor,
"Can't recreate wrapped extractors. Outer type: %s",
extractor.getClass());
Extractor newExtractorInstance;
// LINT.IfChange(extractor_instantiation)
if (extractor instanceof WebvttExtractor) {
newExtractorInstance =
new WebvttExtractor(
multivariantPlaylistFormat.language,
timestampAdjuster,
subtitleParserFactory,
parseSubtitlesDuringExtraction);
} else if (extractor instanceof AdtsExtractor) {
newExtractorInstance = new AdtsExtractor();
} else if (extractor instanceof Ac3Extractor) {
newExtractorInstance = new Ac3Extractor();
} else if (extractor instanceof Ac4Extractor) {
newExtractorInstance = new Ac4Extractor();
} else if (extractor instanceof Mp3Extractor) {
newExtractorInstance = new Mp3Extractor();
} else {
throw new IllegalStateException(
"Unexpected extractor type for recreation: " + extractor.getClass().getSimpleName());
}
// LINT.ThenChange(DefaultHlsExtractorFactory.java:extractor_instantiation)
return new BundledHlsMediaChunkExtractor(
newExtractorInstance,
multivariantPlaylistFormat,
timestampAdjuster,
subtitleParserFactory,
parseSubtitlesDuringExtraction);
}
@Override
public void onTruncatedSegmentParsed() {
extractor.seek(/* position= */ 0, /* timeUs= */ 0);
}
}
@@ -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 androidx.media3.exoplayer.hls;
import androidx.media3.common.C;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
/** Default implementation of {@link HlsDataSourceFactory}. */
@UnstableApi
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(@C.DataType int dataType) {
return dataSourceFactory.createDataSource();
}
}
@@ -0,0 +1,356 @@
/*
* 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 androidx.media3.exoplayer.hls;
import static androidx.media3.extractor.ts.TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES;
import static com.google.common.base.Preconditions.checkNotNull;
import android.annotation.SuppressLint;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.FileTypes;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.mp3.Mp3Extractor;
import androidx.media3.extractor.mp4.FragmentedMp4Extractor;
import androidx.media3.extractor.text.DefaultSubtitleParserFactory;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.media3.extractor.ts.Ac3Extractor;
import androidx.media3.extractor.ts.Ac4Extractor;
import androidx.media3.extractor.ts.AdtsExtractor;
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory;
import androidx.media3.extractor.ts.TsExtractor;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.EOFException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/** Default {@link HlsExtractorFactory} implementation. */
@UnstableApi
public final class DefaultHlsExtractorFactory implements HlsExtractorFactory {
// Extractors order is optimized according to
// https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ.
private static final int[] DEFAULT_EXTRACTOR_ORDER =
new int[] {
FileTypes.MP4,
FileTypes.WEBVTT,
FileTypes.TS,
FileTypes.ADTS,
FileTypes.AC3,
FileTypes.AC4,
FileTypes.MP3,
};
private final @DefaultTsPayloadReaderFactory.Flags int payloadReaderFactoryFlags;
private SubtitleParser.Factory subtitleParserFactory;
private boolean parseSubtitlesDuringExtraction;
private @C.VideoCodecFlags int codecsToParseWithinGopSampleDependencies;
private final boolean exposeCea608WhenMissingDeclarations;
/**
* Equivalent to {@link #DefaultHlsExtractorFactory(int, boolean) new
* DefaultHlsExtractorFactory(payloadReaderFactoryFlags = 0, exposeCea608WhenMissingDeclarations =
* true)}
*/
public DefaultHlsExtractorFactory() {
this(/* payloadReaderFactoryFlags= */ 0, /* exposeCea608WhenMissingDeclarations */ true);
codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 | C.VIDEO_CODEC_FLAG_H265;
}
/**
* 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 multivariant playlist contain no Closed Captions
* declarations. If the multivariant playlist contains any Closed Captions declarations, this
* flag is ignored.
*/
public DefaultHlsExtractorFactory(
int payloadReaderFactoryFlags, boolean exposeCea608WhenMissingDeclarations) {
this.payloadReaderFactoryFlags = payloadReaderFactoryFlags;
this.exposeCea608WhenMissingDeclarations = exposeCea608WhenMissingDeclarations;
subtitleParserFactory = new DefaultSubtitleParserFactory();
}
@Override
public BundledHlsMediaChunkExtractor createExtractor(
Uri uri,
Format format,
@Nullable List<Format> muxedCaptionFormats,
TimestampAdjuster timestampAdjuster,
Map<String, List<String>> responseHeaders,
ExtractorInput sniffingExtractorInput,
PlayerId playerId)
throws IOException {
@FileTypes.Type
int formatInferredFileType = FileTypes.inferFileTypeFromMimeType(format.sampleMimeType);
@FileTypes.Type
int responseHeadersInferredFileType =
FileTypes.inferFileTypeFromResponseHeaders(responseHeaders);
@FileTypes.Type int uriInferredFileType = FileTypes.inferFileTypeFromUri(uri);
// Defines the order in which to try the extractors.
List<Integer> fileTypeOrder =
new ArrayList<>(/* initialCapacity= */ DEFAULT_EXTRACTOR_ORDER.length);
addFileTypeIfValidAndNotPresent(formatInferredFileType, fileTypeOrder);
addFileTypeIfValidAndNotPresent(responseHeadersInferredFileType, fileTypeOrder);
addFileTypeIfValidAndNotPresent(uriInferredFileType, fileTypeOrder);
for (int fileType : DEFAULT_EXTRACTOR_ORDER) {
addFileTypeIfValidAndNotPresent(fileType, fileTypeOrder);
}
// Extractor to be used if the type is not recognized.
@Nullable Extractor fallBackExtractor = null;
sniffingExtractorInput.resetPeekPosition();
for (int i = 0; i < fileTypeOrder.size(); i++) {
int fileType = fileTypeOrder.get(i);
Extractor extractor =
checkNotNull(
createExtractorByFileType(fileType, format, muxedCaptionFormats, timestampAdjuster));
if (sniffQuietly(extractor, sniffingExtractorInput)) {
return new BundledHlsMediaChunkExtractor(
extractor,
format,
timestampAdjuster,
subtitleParserFactory,
parseSubtitlesDuringExtraction);
}
if (fallBackExtractor == null
&& (fileType == formatInferredFileType
|| fileType == responseHeadersInferredFileType
|| fileType == uriInferredFileType
|| fileType == FileTypes.TS)) {
// If sniffing fails, fallback to the file types inferred from context. If all else fails,
// fallback to Transport Stream. See https://github.com/google/ExoPlayer/issues/8219.
fallBackExtractor = extractor;
}
}
return new BundledHlsMediaChunkExtractor(
checkNotNull(fallBackExtractor),
format,
timestampAdjuster,
subtitleParserFactory,
parseSubtitlesDuringExtraction);
}
@CanIgnoreReturnValue
@Override
public DefaultHlsExtractorFactory setSubtitleParserFactory(
SubtitleParser.Factory subtitleParserFactory) {
this.subtitleParserFactory = subtitleParserFactory;
return this;
}
@CanIgnoreReturnValue
@Override
public DefaultHlsExtractorFactory experimentalParseSubtitlesDuringExtraction(
boolean parseSubtitlesDuringExtraction) {
this.parseSubtitlesDuringExtraction = parseSubtitlesDuringExtraction;
return this;
}
@CanIgnoreReturnValue
@Override
public DefaultHlsExtractorFactory experimentalSetCodecsToParseWithinGopSampleDependencies(
@C.VideoCodecFlags int codecsToParseWithinGopSampleDependencies) {
this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies;
return this;
}
/**
* {@inheritDoc}
*
* <p>This implementation performs transcoding of the original format to {@link
* MimeTypes#APPLICATION_MEDIA3_CUES} if it is supported by {@link SubtitleParser.Factory}.
*
* <p>To modify the support behavior, you can {@linkplain
* #setSubtitleParserFactory(SubtitleParser.Factory) set your own subtitle parser factory}.
*/
@Override
public Format getOutputTextFormat(Format sourceFormat) {
if (parseSubtitlesDuringExtraction && subtitleParserFactory.supportsFormat(sourceFormat)) {
return sourceFormat
.buildUpon()
.setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES)
.setCueReplacementBehavior(subtitleParserFactory.getCueReplacementBehavior(sourceFormat))
.setCodecs(
sourceFormat.sampleMimeType
+ (sourceFormat.codecs != null ? " " + sourceFormat.codecs : ""))
.setSubsampleOffsetUs(Format.OFFSET_SAMPLE_RELATIVE)
.build();
} else {
return sourceFormat;
}
}
private static void addFileTypeIfValidAndNotPresent(
@FileTypes.Type int fileType, List<Integer> fileTypes) {
if (Ints.indexOf(DEFAULT_EXTRACTOR_ORDER, fileType) == -1 || fileTypes.contains(fileType)) {
return;
}
fileTypes.add(fileType);
}
@SuppressLint("SwitchIntDef") // HLS only supports a small subset of the defined file types.
@Nullable
private Extractor createExtractorByFileType(
@FileTypes.Type int fileType,
Format format,
@Nullable List<Format> muxedCaptionFormats,
TimestampAdjuster timestampAdjuster) {
// LINT.IfChange(extractor_instantiation)
switch (fileType) {
case FileTypes.WEBVTT:
return new WebvttExtractor(
format.language,
timestampAdjuster,
subtitleParserFactory,
parseSubtitlesDuringExtraction);
case FileTypes.ADTS:
return new AdtsExtractor();
case FileTypes.AC3:
return new Ac3Extractor();
case FileTypes.AC4:
return new Ac4Extractor();
case FileTypes.MP3:
return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0);
case FileTypes.MP4:
return createFragmentedMp4Extractor(
subtitleParserFactory,
parseSubtitlesDuringExtraction,
timestampAdjuster,
muxedCaptionFormats,
codecsToParseWithinGopSampleDependencies);
case FileTypes.TS:
return createTsExtractor(
payloadReaderFactoryFlags,
exposeCea608WhenMissingDeclarations,
format,
muxedCaptionFormats,
timestampAdjuster,
subtitleParserFactory,
parseSubtitlesDuringExtraction);
default:
return null;
}
// LINT.ThenChange(BundledHlsMediaChunkExtractor.java:extractor_instantiation)
}
private static TsExtractor createTsExtractor(
@DefaultTsPayloadReaderFactory.Flags int userProvidedPayloadReaderFactoryFlags,
boolean exposeCea608WhenMissingDeclarations,
Format format,
@Nullable List<Format> muxedCaptionFormats,
TimestampAdjuster timestampAdjuster,
SubtitleParser.Factory subtitleParserFactory,
boolean parseSubtitlesDuringExtraction) {
@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(
new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build());
} else {
muxedCaptionFormats = Collections.emptyList();
}
@Nullable 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.containsCodecsCorrespondingToMimeType(codecs, MimeTypes.AUDIO_AAC)) {
payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM;
}
if (!MimeTypes.containsCodecsCorrespondingToMimeType(codecs, MimeTypes.VIDEO_H264)) {
payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM;
}
}
@TsExtractor.Flags int extractorFlags = 0;
if (!parseSubtitlesDuringExtraction) {
subtitleParserFactory = SubtitleParser.Factory.UNSUPPORTED;
extractorFlags |= TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA;
}
return new TsExtractor(
TsExtractor.MODE_HLS,
extractorFlags,
subtitleParserFactory,
timestampAdjuster,
new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats),
DEFAULT_TIMESTAMP_SEARCH_BYTES);
}
private static FragmentedMp4Extractor createFragmentedMp4Extractor(
SubtitleParser.Factory subtitleParserFactory,
boolean parseSubtitlesDuringExtraction,
TimestampAdjuster timestampAdjuster,
@Nullable List<Format> muxedCaptionFormats,
@C.VideoCodecFlags int codecsToParseWithinGopSampleDependencies) {
@FragmentedMp4Extractor.Flags int flags = FragmentedMp4Extractor.FLAG_ENABLE_EMSG_TRACK;
if (!parseSubtitlesDuringExtraction) {
subtitleParserFactory = SubtitleParser.Factory.UNSUPPORTED;
flags |= FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA;
}
flags |=
FragmentedMp4Extractor.codecsToParseWithinGopSampleDependenciesAsFlags(
codecsToParseWithinGopSampleDependencies);
return new FragmentedMp4Extractor(
subtitleParserFactory,
flags,
timestampAdjuster,
/* sideloadedTrack= */ null,
muxedCaptionFormats != null ? muxedCaptionFormats : ImmutableList.of(),
/* additionalEmsgTrackOutput= */ null);
}
private static boolean sniffQuietly(Extractor extractor, ExtractorInput input)
throws IOException {
boolean result = false;
try {
result = extractor.sniff(input);
} catch (EOFException e) {
// Do nothing.
} finally {
input.resetPeekPosition();
}
return result;
}
}
@@ -0,0 +1,86 @@
/*
* 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 androidx.media3.exoplayer.hls;
import static com.google.common.base.Preconditions.checkNotNull;
import android.net.Uri;
import androidx.annotation.Nullable;
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(checkNotNull(uri), 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(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(checkNotNull(uri));
}
}
@@ -0,0 +1,353 @@
/*
* Copyright 2026 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 androidx.media3.exoplayer.hls;
import static androidx.media3.common.C.DATA_TYPE_STEERING_MANIFEST;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.HandlerWrapper;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker;
import androidx.media3.exoplayer.hls.playlist.HlsRedundantGroup;
import androidx.media3.exoplayer.source.MediaSourceEventListener;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import androidx.media3.exoplayer.upstream.contentsteering.ContentSteeringTracker;
import androidx.media3.exoplayer.upstream.contentsteering.SteeringManifest;
import androidx.media3.exoplayer.upstream.contentsteering.SteeringManifestTracker;
import androidx.media3.exoplayer.util.ReleasableExecutor;
import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/** Tracks the content steering states for an HLS stream. */
@UnstableApi
public final class HlsContentSteeringTracker implements ContentSteeringTracker {
/** A callback to be notified of {@link HlsContentSteeringTracker} events. */
public interface Callback {
/**
* Called when the current pathway is updated.
*
* @param currentPathwayId The current pathway ID after the update.
* @param previousPathwayId The pathway ID before the update, or {@code null} if the call of
* this method is the result of starting the tracker.
* @param previousPathwayExcludeDurationMs The exclude duration in milliseconds if the update is
* due to {@linkplain #excludeCurrentPathway(long) the exclusion of the previous pathway},
* or {@link C#TIME_UNSET} if the previous pathway is not excluded.
*/
void onCurrentPathwayUpdated(
String currentPathwayId,
@Nullable String previousPathwayId,
long previousPathwayExcludeDurationMs);
/**
* Called when a new pathway cloned from an existing pathway becomes available.
*
* <p>The lists {@code newPlaylistUrls} and {@code basePlaylistUrls} have the equal size, and
* each URI in the {@code newPlaylistUrls} is cloned from the base URI in the {@code
* basePlaylistUrls} of the same index.
*
* @param newPathwayId The new pathway ID.
* @param basePathwayId The base pathway ID.
* @param newPlaylistUrls The list of new playlist URLs.
* @param basePlaylistUrls The list of base playlist URLs.
*/
void onNewPathwayAvailable(
String newPathwayId,
String basePathwayId,
ImmutableList<Uri> newPlaylistUrls,
ImmutableList<Uri> basePlaylistUrls);
}
private static final String PATHWAY_PARAM = "_HLS_pathway";
private static final String THROUGHPUT_PARAM = "_HLS_throughput";
private final SteeringManifestTracker steeringManifestTracker;
@Nullable private final Callback callback;
private final BandwidthMeter bandwidthMeter;
private final List<HlsRedundantGroup> variantRedundantGroups;
private final List<HlsRedundantGroup> videoRenditionRedundantGroups;
private final List<HlsRedundantGroup> audioRenditionRedundantGroups;
private final List<HlsRedundantGroup> subtitleRenditionRedundantGroups;
private final Set<String> availablePathwayIds;
private final Set<String> excludedPathwayIds;
private final HandlerWrapper handler;
private boolean isActive;
private String currentPathwayId;
@Nullable private ImmutableList<String> currentPathwayPriority;
/**
* Creates an {@link HlsContentSteeringTracker}.
*
* @param dataSourceFactory The {@link HlsDataSourceFactory} to create data sources for loading
* steering manifests.
* @param downloadExecutorSupplier A supplier to obtain a {@link ReleasableExecutor} for
* downloading steering manifests, or {@code null}.
* @param playlistTracker The {@link HlsPlaylistTracker}.
* @param callback A {@link Callback} to receive events, or {@code null}.
* @param bandwidthMeter The {@link BandwidthMeter} to obtain throughput estimates.
* @param clock The {@link Clock} to schedule handler messages.
*/
public HlsContentSteeringTracker(
HlsDataSourceFactory dataSourceFactory,
@Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier,
HlsPlaylistTracker playlistTracker,
@Nullable Callback callback,
BandwidthMeter bandwidthMeter,
Clock clock) {
this.steeringManifestTracker =
new SteeringManifestTracker(
() -> dataSourceFactory.createDataSource(DATA_TYPE_STEERING_MANIFEST),
downloadExecutorSupplier);
this.callback = callback;
this.bandwidthMeter = bandwidthMeter;
this.variantRedundantGroups =
checkNotNull(playlistTracker.getRedundantGroups(HlsRedundantGroup.VARIANT));
this.videoRenditionRedundantGroups =
checkNotNull(playlistTracker.getRedundantGroups(HlsRedundantGroup.VIDEO_RENDITION));
this.audioRenditionRedundantGroups =
checkNotNull(playlistTracker.getRedundantGroups(HlsRedundantGroup.AUDIO_RENDITION));
this.subtitleRenditionRedundantGroups =
checkNotNull(playlistTracker.getRedundantGroups(HlsRedundantGroup.SUBTITLE_RENDITION));
checkState(!variantRedundantGroups.isEmpty());
availablePathwayIds =
new HashSet<>(checkNotNull(variantRedundantGroups.get(0)).getAllPathwayIds());
excludedPathwayIds = new HashSet<>();
handler = clock.createHandler(Util.getCurrentOrMainLooper(), /* callback= */ null);
currentPathwayId = variantRedundantGroups.get(0).getCurrentPathwayId();
}
/**
* {@inheritDoc}
*
* @throws IllegalStateException If the {@code initialPathwayId} is not declared in the redundant
* group.
*/
@Override
public void start(
Uri initialSteeringManifestUri,
@Nullable String initialPathwayId,
MediaSourceEventListener.EventDispatcher eventDispatcher) {
if (initialPathwayId != null) {
checkState(availablePathwayIds.contains(initialPathwayId));
currentPathwayId = initialPathwayId;
}
isActive = true;
notifyOnCurrentPathwayUpdated(
/* previousPathwayId= */ null, /* previousPathwayExcludeDurationMs= */ C.TIME_UNSET);
steeringManifestTracker.start(
initialSteeringManifestUri, new SteeringManifestTrackerCallback(), eventDispatcher);
}
@Override
public boolean excludeCurrentPathway(long excludeDurationMs) {
if (isActive && currentPathwayPriority != null) {
String previousPathwayId = currentPathwayId;
performPathwayEvaluationAndUpdate(
/* previousPathwayIdExcludeDurationMs= */ excludeDurationMs);
if (!currentPathwayId.equals(previousPathwayId)) {
excludedPathwayIds.add(previousPathwayId);
handler.postDelayed(() -> expireExclusion(previousPathwayId), excludeDurationMs);
return true;
}
}
return false;
}
@Override
public boolean isActive() {
return isActive;
}
@Override
public void stop() {
stopInternal();
}
private void expireExclusion(String pathwayId) {
checkState(isActive);
excludedPathwayIds.remove(pathwayId);
if (currentPathwayPriority != null) {
performPathwayEvaluationAndUpdate(/* previousPathwayIdExcludeDurationMs= */ C.TIME_UNSET);
}
}
private void stopInternal() {
steeringManifestTracker.stop();
handler.removeCallbacksAndMessages(null);
isActive = false;
}
@RequiresNonNull("currentPathwayPriority")
private void performPathwayEvaluationAndUpdate(long previousPathwayIdExcludeDurationMs) {
String previousPathwayId = currentPathwayId;
for (String pathwayId : currentPathwayPriority) {
if (previousPathwayIdExcludeDurationMs != C.TIME_UNSET
&& pathwayId.equals(previousPathwayId)) {
continue;
}
if (availablePathwayIds.contains(pathwayId) && !excludedPathwayIds.contains(pathwayId)) {
currentPathwayId = pathwayId;
break;
}
}
if (!currentPathwayId.equals(previousPathwayId)) {
notifyOnCurrentPathwayUpdated(previousPathwayId, previousPathwayIdExcludeDurationMs);
}
}
private void performPathwayClones(ImmutableList<SteeringManifest.PathwayClone> pathwayClones) {
for (SteeringManifest.PathwayClone pathwayClone : pathwayClones) {
if (!availablePathwayIds.contains(pathwayClone.baseId)
|| availablePathwayIds.contains(pathwayClone.id)) {
// We should ignore a PathwayClone if its baseId is unknown to the tracker, or the new
// pathwayId of the clone matches any existing pathwayId.
continue;
}
ImmutableList.Builder<Uri> newPlaylistUrls = new ImmutableList.Builder<>();
ImmutableList.Builder<Uri> basePlaylistUrls = new ImmutableList.Builder<>();
performPathwayClonesForVariants(
pathwayClone, variantRedundantGroups, newPlaylistUrls, basePlaylistUrls);
performPathwayClonesForRenditions(
pathwayClone, videoRenditionRedundantGroups, newPlaylistUrls, basePlaylistUrls);
performPathwayClonesForRenditions(
pathwayClone, audioRenditionRedundantGroups, newPlaylistUrls, basePlaylistUrls);
performPathwayClonesForRenditions(
pathwayClone, subtitleRenditionRedundantGroups, newPlaylistUrls, basePlaylistUrls);
availablePathwayIds.add(pathwayClone.id);
if (callback != null) {
callback.onNewPathwayAvailable(
pathwayClone.id,
pathwayClone.baseId,
newPlaylistUrls.build(),
basePlaylistUrls.build());
}
}
}
private void performPathwayClonesForVariants(
SteeringManifest.PathwayClone pathwayClone,
List<HlsRedundantGroup> variantRedundantGroups,
ImmutableList.Builder<Uri> newPlaylistUrls,
ImmutableList.Builder<Uri> basePlaylistUrls) {
ImmutableMap<String, Uri> perVariantUris = pathwayClone.uriReplacement.perVariantUris;
for (HlsRedundantGroup variantRedundantGroup : variantRedundantGroups) {
String basePathwayId = pathwayClone.baseId;
Uri basePlaylistUrl = checkNotNull(variantRedundantGroup.getPlaylistUrl(basePathwayId));
@Nullable
Uri newPlaylistUrl =
perVariantUris.containsKey(variantRedundantGroup.groupKey.stableId)
? checkNotNull(perVariantUris.get(variantRedundantGroup.groupKey.stableId))
: getUriBuilder(basePlaylistUrl, pathwayClone.uriReplacement).build();
if (newPlaylistUrl != null) {
newPlaylistUrls.add(newPlaylistUrl);
basePlaylistUrls.add(basePlaylistUrl);
}
}
}
private void performPathwayClonesForRenditions(
SteeringManifest.PathwayClone pathwayClone,
List<HlsRedundantGroup> renditionRedundantGroups,
ImmutableList.Builder<Uri> newPlaylistUrls,
ImmutableList.Builder<Uri> basePlaylistUrls) {
ImmutableMap<String, Uri> perRenditionUris = pathwayClone.uriReplacement.perRenditionUris;
for (HlsRedundantGroup renditionRedundantGroup : renditionRedundantGroups) {
String basePathwayId = pathwayClone.baseId;
Uri basePlaylistUrl = checkNotNull(renditionRedundantGroup.getPlaylistUrl(basePathwayId));
@Nullable
Uri newPlaylistUrl =
perRenditionUris.containsKey(renditionRedundantGroup.groupKey.stableId)
? checkNotNull(perRenditionUris.get(renditionRedundantGroup.groupKey.stableId))
: getUriBuilder(basePlaylistUrl, pathwayClone.uriReplacement).build();
if (newPlaylistUrl != null) {
newPlaylistUrls.add(newPlaylistUrl);
basePlaylistUrls.add(basePlaylistUrl);
}
}
}
private Uri.Builder getUriBuilder(Uri url, SteeringManifest.UriReplacement uriReplacement) {
Uri.Builder newUrlBuilder = url.buildUpon().clearQuery();
if (uriReplacement.host != null) {
newUrlBuilder.authority(uriReplacement.host);
}
// Combine existing and new query parameters, giving precedence to new ones.
Map<String, String> combinedParams = new TreeMap<>();
// Add existing parameters.
for (String existingParamName : url.getQueryParameterNames()) {
combinedParams.put(existingParamName, checkNotNull(url.getQueryParameter(existingParamName)));
}
// Add new parameters, overwriting existing ones if keys clash.
combinedParams.putAll(uriReplacement.params);
// Append all parameters from the sorted map.
for (Map.Entry<String, String> param : combinedParams.entrySet()) {
newUrlBuilder.appendQueryParameter(param.getKey(), param.getValue());
}
return newUrlBuilder;
}
private void notifyOnCurrentPathwayUpdated(
@Nullable String previousPathwayId, long previousPathwayExcludeDurationMs) {
if (callback != null) {
callback.onCurrentPathwayUpdated(
currentPathwayId, previousPathwayId, previousPathwayExcludeDurationMs);
}
}
private class SteeringManifestTrackerCallback implements SteeringManifestTracker.Callback {
@Override
public ImmutableMap<String, String> getSteeringQueryParameters() {
checkState(isActive);
return ImmutableMap.of(
PATHWAY_PARAM,
currentPathwayId,
THROUGHPUT_PARAM,
String.valueOf(bandwidthMeter.getBitrateEstimate()));
}
@Override
public void onSteeringManifestUpdated(SteeringManifest steeringManifest) {
checkState(isActive);
performPathwayClones(steeringManifest.pathwayClones);
currentPathwayPriority = steeringManifest.pathwayPriority;
performPathwayEvaluationAndUpdate(/* previousPathwayIdExcludeDurationMs= */ C.TIME_UNSET);
}
@Override
public void onSteeringManifestLoadError(IOException error, boolean canceled) {
checkState(isActive);
if (canceled && currentPathwayPriority == null) {
stopInternal();
}
}
}
}
@@ -0,0 +1,33 @@
/*
* 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 androidx.media3.exoplayer.hls;
import androidx.media3.common.C;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
/** Creates {@link DataSource}s for HLS playlists, encryption and media chunks. */
@UnstableApi
public interface HlsDataSourceFactory {
/**
* Creates a {@link DataSource} for the given data type.
*
* @param dataType The {@link C.DataType} for which the {@link DataSource} will be used.
* @return A {@link DataSource} for the given data type.
*/
DataSource createDataSource(@C.DataType int dataType);
}
@@ -0,0 +1,146 @@
/*
* 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 androidx.media3.exoplayer.hls;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.ExperimentalApi;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.mp4.FragmentedMp4Extractor;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/** Factory for HLS media chunk extractors. */
@UnstableApi
public interface HlsExtractorFactory {
/**
* @deprecated {@code HlsExtractorFactory} instances are mutable, so sharing one in a static field
* is not safe. Construct a new instance of {@link DefaultHlsExtractorFactory} for each usage
* instead.
*/
@Deprecated HlsExtractorFactory DEFAULT = new DefaultHlsExtractorFactory();
/**
* Creates an {@link Extractor} for extracting HLS media chunks.
*
* @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 multivariant 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)}.
* @param playerId The {@link PlayerId} of the player using this extractors factory.
* @return An {@link HlsMediaChunkExtractor}.
* @throws IOException If an I/O error is encountered while sniffing.
*/
HlsMediaChunkExtractor createExtractor(
Uri uri,
Format format,
@Nullable List<Format> muxedCaptionFormats,
TimestampAdjuster timestampAdjuster,
Map<String, List<String>> responseHeaders,
ExtractorInput sniffingExtractorInput,
PlayerId playerId)
throws IOException;
/**
* Sets the {@link SubtitleParser.Factory} to use for parsing subtitles during extraction. The
* default factory value is implementation dependent.
*
* @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during
* extraction.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
default HlsExtractorFactory setSubtitleParserFactory(
SubtitleParser.Factory subtitleParserFactory) {
return this;
}
/**
* Sets whether subtitles should be parsed as part of extraction (before being added to the sample
* queue) or as part of rendering (when being taken from the sample queue). Defaults to {@code
* false} (i.e. subtitles will be parsed as part of rendering).
*
* <p>This method is experimental and will be renamed or removed in a future release.
*
* @param parseSubtitlesDuringExtraction Whether to parse subtitles during extraction or
* rendering.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
@ExperimentalApi // TODO: b/289983417 - Remove legacy subtitle decoding paths.
default HlsExtractorFactory experimentalParseSubtitlesDuringExtraction(
boolean parseSubtitlesDuringExtraction) {
return this;
}
/**
* Sets the set of video codecs for which within GOP sample dependency information should be
* parsed as part of extraction. Defaults to H.264 and H.265.
*
* <p>Having access to additional sample dependency information can speed up seeking. See {@link
* FragmentedMp4Extractor#FLAG_READ_WITHIN_GOP_SAMPLE_DEPENDENCIES}.
*
* <p>This method is experimental and will be renamed or removed in a future release.
*
* @param codecsToParseWithinGopSampleDependencies The set of codecs for which to parse within GOP
* sample dependency information.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
@ExperimentalApi // TODO: b/470365670 - Remove method once config is enabled by default.
default HlsExtractorFactory experimentalSetCodecsToParseWithinGopSampleDependencies(
@C.VideoCodecFlags int codecsToParseWithinGopSampleDependencies) {
return this;
}
/**
* Returns the output {@link Format} of emitted {@linkplain C#TRACK_TYPE_TEXT text samples} which
* were originally in {@code sourceFormat}.
*
* <p>In many cases, where an {@link Extractor} emits samples from the source without mutation,
* this method simply returns {@code sourceFormat}. In other cases, such as an {@link Extractor}
* that transcodes subtitles from the {@code sourceFormat} to {@link
* MimeTypes#APPLICATION_MEDIA3_CUES}, the format is updated to indicate the transcoding that is
* taking place.
*
* <p>Non-text source formats are always returned without mutation.
*
* @param sourceFormat The original text-based format.
* @return The {@link Format} that will be associated with a {@linkplain C#TRACK_TYPE_TEXT text
* track}.
*/
default Format getOutputTextFormat(Format sourceFormat) {
return sourceFormat;
}
}
@@ -0,0 +1,41 @@
/*
* 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 androidx.media3.exoplayer.hls;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist;
/** Holds a multivariant playlist along with a snapshot of one of its media playlists. */
@UnstableApi
public final class HlsManifest {
/** The multivariant playlist of an HLS stream. */
public final HlsMultivariantPlaylist multivariantPlaylist;
/** A snapshot of a media playlist referred to by {@link #multivariantPlaylist}. */
public final HlsMediaPlaylist mediaPlaylist;
/**
* @param multivariantPlaylist The multivariant playlist.
* @param mediaPlaylist The media playlist.
*/
/* package */ HlsManifest(
HlsMultivariantPlaylist multivariantPlaylist, HlsMediaPlaylist mediaPlaylist) {
this.multivariantPlaylist = multivariantPlaylist;
this.mediaPlaylist = mediaPlaylist;
}
}
@@ -0,0 +1,703 @@
/*
* 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 androidx.media3.exoplayer.hls;
import static androidx.media3.datasource.DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.DrmInitData;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UriUtil;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSourceUtil;
import androidx.media3.datasource.DataSpec;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
import androidx.media3.exoplayer.source.chunk.MediaChunk;
import androidx.media3.exoplayer.upstream.CmcdData;
import androidx.media3.extractor.DefaultExtractorInput;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.metadata.id3.Id3Decoder;
import androidx.media3.extractor.metadata.id3.PrivFrame;
import com.google.common.base.Ascii;
import com.google.common.collect.ImmutableList;
import java.io.EOFException;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.math.BigInteger;
import java.util.List;
import java.util.concurrent.TimeoutException;
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 An {@link HlsExtractorFactory} for creating {@link
* HlsMediaChunkExtractor}s.
* @param dataSource The {@link DataSource} for loading the data.
* @param format The chunk {@link Format}.
* @param startOfPlaylistInPeriodUs The start time of the playlist in the period, in microseconds.
* @param mediaPlaylist The {@link HlsMediaPlaylist} from which this chunk was obtained.
* @param segmentBaseHolder The segment holder.
* @param playlistUrl The url of the playlist from which this chunk was obtained.
* @param steeredPathwayId The ID of the steered pathway from which data is being loaded, or
* {@code null} if not applicable.
* @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption
* information is available in the multivariant playlist.
* @param trackSelectionReason See {@link #trackSelectionReason}.
* @param trackSelectionData See {@link #trackSelectionData}.
* @param isPrimaryTimestampSource Whether this chunk is providing the timestamp source.
* @param timestampAdjusterProvider A provider of {@link TimestampAdjuster}s.
* @param timestampAdjusterInitializationTimeoutMs The timeout for waiting for the timestamp
* adjuster to be initialized, in milliseconds.
* @param previousChunk The previous chunk in the output, or 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.
* @param shouldSpliceIn Whether samples for this chunk should be spliced into existing samples.
* @param isIndependent Whether the chunk starts with a keyframe.
* @param playerId The {@link PlayerId} of the player.
* @param cmcdDataFactory The {@link CmcdData.Factory} for generating {@link CmcdData}.
*/
public static HlsMediaChunk createInstance(
HlsExtractorFactory extractorFactory,
DataSource dataSource,
Format format,
long startOfPlaylistInPeriodUs,
HlsMediaPlaylist mediaPlaylist,
HlsChunkSource.SegmentBaseHolder segmentBaseHolder,
Uri playlistUrl,
@Nullable String steeredPathwayId,
@Nullable List<Format> muxedCaptionFormats,
@C.SelectionReason int trackSelectionReason,
@Nullable Object trackSelectionData,
boolean isPrimaryTimestampSource,
TimestampAdjusterProvider timestampAdjusterProvider,
long timestampAdjusterInitializationTimeoutMs,
@Nullable HlsMediaChunk previousChunk,
@Nullable byte[] mediaSegmentKey,
@Nullable byte[] initSegmentKey,
boolean shouldSpliceIn,
boolean isIndependent,
PlayerId playerId,
@Nullable CmcdData.Factory cmcdDataFactory) {
// Media segment.
HlsMediaPlaylist.SegmentBase mediaSegment = segmentBaseHolder.segmentBase;
DataSpec dataSpec =
new DataSpec.Builder()
.setUri(UriUtil.resolveToUri(mediaPlaylist.baseUri, mediaSegment.url))
.setPosition(mediaSegment.byteRangeOffset)
.setLength(mediaSegment.byteRangeLength)
.setFlags(segmentBaseHolder.isPreload ? FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED : 0)
.build();
if (cmcdDataFactory != null) {
CmcdData cmcdData = cmcdDataFactory.createCmcdData();
dataSpec = cmcdData.addToDataSpec(dataSpec);
}
boolean mediaSegmentEncrypted = mediaSegmentKey != null;
@Nullable
byte[] mediaSegmentIv =
mediaSegmentEncrypted
? getEncryptionIvArray(checkNotNull(mediaSegment.encryptionIV))
: null;
DataSource mediaDataSource = buildDataSource(dataSource, mediaSegmentKey, mediaSegmentIv);
// Init segment.
HlsMediaPlaylist.Segment initSegment = mediaSegment.initializationSegment;
DataSpec initDataSpec = null;
boolean initSegmentEncrypted = false;
@Nullable DataSource initDataSource = null;
if (initSegment != null) {
initSegmentEncrypted = initSegmentKey != null;
@Nullable
byte[] initSegmentIv =
initSegmentEncrypted
? getEncryptionIvArray(checkNotNull(initSegment.encryptionIV))
: null;
Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url);
initDataSpec =
new DataSpec.Builder()
.setUri(initSegmentUri)
.setPosition(initSegment.byteRangeOffset)
.setLength(initSegment.byteRangeLength)
.build();
if (cmcdDataFactory != null) {
CmcdData cmcdData =
cmcdDataFactory.setObjectType(CmcdData.OBJECT_TYPE_INIT_SEGMENT).createCmcdData();
initDataSpec = cmcdData.addToDataSpec(initDataSpec);
}
initDataSource = buildDataSource(dataSource, initSegmentKey, initSegmentIv);
}
long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + mediaSegment.relativeStartTimeUs;
long segmentEndTimeInPeriodUs = segmentStartTimeInPeriodUs + mediaSegment.durationUs;
int discontinuitySequenceNumber =
mediaPlaylist.discontinuitySequence + mediaSegment.relativeDiscontinuitySequence;
@Nullable HlsMediaChunkExtractor previousExtractor = null;
Id3Decoder id3Decoder;
ParsableByteArray scratchId3Data;
if (previousChunk != null) {
boolean isSameInitData =
initDataSpec == previousChunk.initDataSpec
|| (initDataSpec != null
&& previousChunk.initDataSpec != null
&& initDataSpec.uri.equals(previousChunk.initDataSpec.uri)
&& initDataSpec.position == previousChunk.initDataSpec.position);
boolean isFollowingChunk =
playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted;
id3Decoder = previousChunk.id3Decoder;
scratchId3Data = previousChunk.scratchId3Data;
previousExtractor =
isSameInitData
&& isFollowingChunk
&& !previousChunk.extractorInvalidated
&& previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber
? previousChunk.extractor
: null;
} else {
id3Decoder = new Id3Decoder();
scratchId3Data = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH);
}
return new HlsMediaChunk(
extractorFactory,
mediaDataSource,
dataSpec,
format,
mediaSegmentEncrypted,
initDataSource,
initDataSpec,
initSegmentEncrypted,
playlistUrl,
muxedCaptionFormats,
trackSelectionReason,
trackSelectionData,
segmentStartTimeInPeriodUs,
segmentEndTimeInPeriodUs,
segmentBaseHolder.mediaSequence,
segmentBaseHolder.partIndex,
/* isPublished= */ !segmentBaseHolder.isPreload,
discontinuitySequenceNumber,
mediaSegment.hasGapTag,
isPrimaryTimestampSource,
/* timestampAdjuster= */ timestampAdjusterProvider.getAdjuster(discontinuitySequenceNumber),
timestampAdjusterInitializationTimeoutMs,
mediaSegment.drmInitData,
previousExtractor,
id3Decoder,
scratchId3Data,
shouldSpliceIn,
isIndependent,
playerId,
steeredPathwayId);
}
/**
* Returns whether samples of a new HLS media chunk should be spliced into existing samples.
*
* @param previousChunk The previous existing media chunk, or null if the new chunk is the first
* in the queue.
* @param bufferedPositionUs The position in the sample stream in microseconds since the start of
* the period up to which data is already buffered.
* @param playlistUrl The URL of the playlist from which the new chunk will be obtained.
* @param isIndependent Whether the new chunk is independent (i.e, starts with a keyframe).
* @param segmentBaseHolder The {@link HlsChunkSource.SegmentBaseHolder} with information about
* the new chunk.
* @param startOfPlaylistInPeriodUs The start time of the playlist in the period, in microseconds.
* @return Whether samples of the new chunk should be spliced into existing samples.
*/
public static boolean shouldSpliceIn(
@Nullable HlsMediaChunk previousChunk,
long bufferedPositionUs,
Uri playlistUrl,
boolean isIndependent,
HlsChunkSource.SegmentBaseHolder segmentBaseHolder,
long startOfPlaylistInPeriodUs) {
if (previousChunk == null) {
// First chunk doesn't require splicing.
return false;
}
if (playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted) {
// Continuing with the next chunk in the same playlist after fully loading the previous chunk
// (i.e. the load wasn't cancelled or failed) is always possible.
return false;
}
// Changing playlists or continuing after a chunk cancellation/failure requires independent,
// non-overlapping segments to avoid the splice.
long segmentStartTimeInPeriodUs =
startOfPlaylistInPeriodUs + segmentBaseHolder.segmentBase.relativeStartTimeUs;
return !isIndependent || segmentStartTimeInPeriodUs < bufferedPositionUs;
}
public static final String PRIV_TIMESTAMP_FRAME_OWNER =
"com.apple.streaming.transportStreamTimestamp";
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;
/** Whether the chunk is independent, meaning it starts with a keyframe. */
public final boolean isIndependent;
/** The part index or {@link C#INDEX_UNSET} if the chunk is a full segment */
public final int partIndex;
@Nullable private final DataSource initDataSource;
@Nullable private final DataSpec initDataSpec;
@Nullable private final HlsMediaChunkExtractor previousExtractor;
private final boolean isPrimaryTimestampSource;
private final boolean hasGapTag;
private final TimestampAdjuster timestampAdjuster;
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;
private final PlayerId playerId;
private final long timestampAdjusterInitializationTimeoutMs;
private @MonotonicNonNull HlsMediaChunkExtractor extractor;
private @MonotonicNonNull 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 ImmutableList<Integer> sampleQueueFirstSampleIndices;
private boolean extractorInvalidated;
private long publishedDurationUs;
private boolean shouldSpliceIn;
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,
@C.SelectionReason int trackSelectionReason,
@Nullable Object trackSelectionData,
long startTimeUs,
long endTimeUs,
long chunkMediaSequence,
int partIndex,
boolean isPublished,
int discontinuitySequenceNumber,
boolean hasGapTag,
boolean isPrimaryTimestampSource,
TimestampAdjuster timestampAdjuster,
long timestampAdjusterInitializationTimeoutMs,
@Nullable DrmInitData drmInitData,
@Nullable HlsMediaChunkExtractor previousExtractor,
Id3Decoder id3Decoder,
ParsableByteArray scratchId3Data,
boolean shouldSpliceIn,
boolean isIndependent,
PlayerId playerId,
@Nullable String steeredPathwayId) {
super(
mediaDataSource,
dataSpec,
format,
trackSelectionReason,
trackSelectionData,
startTimeUs,
endTimeUs,
chunkMediaSequence,
steeredPathwayId);
this.mediaSegmentEncrypted = mediaSegmentEncrypted;
this.partIndex = partIndex;
this.publishedDurationUs = isPublished ? endTimeUs - startTimeUs : C.TIME_UNSET;
this.discontinuitySequenceNumber = discontinuitySequenceNumber;
this.initDataSpec = initDataSpec;
this.initDataSource = initDataSource;
this.initDataLoadRequired = initDataSpec != null;
this.initSegmentEncrypted = initSegmentEncrypted;
this.playlistUrl = playlistUrl;
this.isPrimaryTimestampSource = isPrimaryTimestampSource;
this.timestampAdjuster = timestampAdjuster;
this.timestampAdjusterInitializationTimeoutMs = timestampAdjusterInitializationTimeoutMs;
this.hasGapTag = hasGapTag;
this.extractorFactory = extractorFactory;
this.muxedCaptionFormats = muxedCaptionFormats;
this.drmInitData = drmInitData;
this.previousExtractor = previousExtractor;
this.id3Decoder = id3Decoder;
this.scratchId3Data = scratchId3Data;
this.shouldSpliceIn = shouldSpliceIn;
this.isIndependent = isIndependent;
this.playerId = playerId;
sampleQueueFirstSampleIndices = ImmutableList.of();
uid = uidSource.getAndIncrement();
}
/**
* Initializes the chunk for loading.
*
* @param output The {@link HlsSampleStreamWrapper} that will receive the loaded samples.
* @param sampleQueueWriteIndices The current write indices in the existing sample queues of the
* output.
*/
public void init(HlsSampleStreamWrapper output, ImmutableList<Integer> sampleQueueWriteIndices) {
this.output = output;
this.sampleQueueFirstSampleIndices = sampleQueueWriteIndices;
}
/**
* Returns the first sample index of this chunk in the specified sample queue in the output.
*
* <p>Must not be used if {@link #shouldSpliceIn} is true.
*
* @param sampleQueueIndex The index of the sample queue in the output.
* @return The first sample index of this chunk in the specified sample queue.
*/
public int getFirstSampleIndex(int sampleQueueIndex) {
checkState(!shouldSpliceIn);
if (sampleQueueIndex >= sampleQueueFirstSampleIndices.size()) {
// The sample queue was created by this chunk or a later chunk.
return 0;
}
return sampleQueueFirstSampleIndices.get(sampleQueueIndex);
}
/** Prevents the extractor from being reused by a following media chunk. */
public void invalidateExtractor() {
extractorInvalidated = true;
}
/** Returns whether samples for this chunk should be spliced into existing samples. */
@SuppressWarnings("UngroupedOverloads") // Ungrouped static method with same name
public boolean shouldSpliceIn() {
return shouldSpliceIn;
}
/**
* Clears the {@linkplain #shouldSpliceIn() flag} that indicates if this chunk should be spliced
* into existing samples.
*/
public void clearShouldSpliceIn() {
shouldSpliceIn = false;
}
@Override
public boolean isLoadCompleted() {
return loadCompleted;
}
// Loadable implementation
@Override
public void cancelLoad() {
loadCanceled = true;
}
@Override
public void load() throws IOException {
// output == null means init() hasn't been called.
checkNotNull(output);
if (extractor == null && previousExtractor != null && previousExtractor.isReusable()) {
extractor = previousExtractor;
initDataLoadRequired = false;
}
maybeLoadInitData();
if (!loadCanceled) {
if (!hasGapTag) {
loadMedia();
}
loadCompleted = !loadCanceled;
}
}
/**
* Whether the chunk is a published chunk as opposed to a preload hint that may change when the
* playlist updates.
*/
public boolean isPublished() {
return publishedDurationUs != C.TIME_UNSET;
}
/**
* Returns the end time of a segment or part if it's fully published, or {@link C#TIME_UNSET} if
* it's an unpublished preload hint.
*
* <p>Note that this value can differ from {@link #endTimeUs} for preload parts that have been
* loaded before the duration was known.
*/
public long getPublishedEndTimeUs() {
return publishedDurationUs != C.TIME_UNSET ? startTimeUs + publishedDurationUs : C.TIME_UNSET;
}
/**
* Sets the publish flag of the media chunk to indicate that it is not based on a part that is a
* preload hint in the playlist.
*
* @param publishedDurationUs The final published duration of the part in microseconds.
*/
public void publish(long publishedDurationUs) {
this.publishedDurationUs = publishedDurationUs;
}
// Internal methods.
@RequiresNonNull("output")
private void maybeLoadInitData() throws IOException {
if (!initDataLoadRequired) {
return;
}
// initDataLoadRequired => initDataSource != null && initDataSpec != null
checkNotNull(initDataSource);
checkNotNull(initDataSpec);
feedDataToExtractor(
initDataSource,
initDataSpec,
initSegmentEncrypted,
/* initializeTimestampAdjuster= */ false);
nextLoadPosition = 0;
initDataLoadRequired = false;
}
@RequiresNonNull("output")
private void loadMedia() throws IOException {
feedDataToExtractor(
dataSource, dataSpec, mediaSegmentEncrypted, /* initializeTimestampAdjuster= */ true);
}
/**
* 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,
boolean initializeTimestampAdjuster)
throws IOException {
// 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, initializeTimestampAdjuster);
if (skipLoadedBytes) {
input.skipFully(nextLoadPosition);
}
try {
while (!loadCanceled && extractor.read(input)) {}
} catch (EOFException e) {
if ((trackFormat.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) {
// See onTruncatedSegmentParsed's javadoc for more info on why we are swallowing the EOF
// exception for trick play tracks.
extractor.onTruncatedSegmentParsed();
} else if (dataSpec.length != C.LENGTH_UNSET
&& input.getPosition() >= dataSpec.position + dataSpec.length) {
// The chunk is fully loaded, but an EOFException was thrown. This can happen if the
// chunk is corrupted.
throw ParserException.createForMalformedContainer(/* message= */ null, e);
} else {
throw e;
}
} finally {
nextLoadPosition = (int) (input.getPosition() - dataSpec.position);
}
} finally {
DataSourceUtil.closeQuietly(dataSource);
}
}
@RequiresNonNull("output")
@EnsuresNonNull("extractor")
private DefaultExtractorInput prepareExtraction(
DataSource dataSource, DataSpec dataSpec, boolean initializeTimestampAdjuster)
throws IOException {
long bytesToRead = dataSource.open(dataSpec);
if (initializeTimestampAdjuster) {
try {
timestampAdjuster.sharedInitializeOrWait(
isPrimaryTimestampSource, startTimeUs, timestampAdjusterInitializationTimeoutMs);
} catch (InterruptedException e) {
throw new InterruptedIOException();
} catch (TimeoutException e) {
throw new IOException(e);
}
}
DefaultExtractorInput extractorInput =
new DefaultExtractorInput(dataSource, dataSpec.position, bytesToRead);
if (extractor == null) {
long id3Timestamp = peekId3PrivTimestamp(extractorInput);
extractorInput.resetPeekPosition();
extractor =
previousExtractor != null
? previousExtractor.recreate()
: extractorFactory.createExtractor(
dataSpec.uri,
trackFormat,
muxedCaptionFormats,
timestampAdjuster,
dataSource.getResponseHeaders(),
extractorInput,
playerId);
if (extractor.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.
*/
private long peekId3PrivTimestamp(ExtractorInput input) throws IOException {
input.resetPeekPosition();
try {
scratchId3Data.reset(Id3Decoder.ID3_HEADER_LENGTH);
input.peekFully(scratchId3Data.getData(), 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;
}
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.getData();
scratchId3Data.reset(requiredCapacity);
System.arraycopy(data, 0, scratchId3Data.getData(), 0, Id3Decoder.ID3_HEADER_LENGTH);
}
input.peekFully(scratchId3Data.getData(), Id3Decoder.ID3_HEADER_LENGTH, id3Size);
Metadata metadata = id3Decoder.decode(scratchId3Data.getData(), id3Size);
if (metadata == null) {
return C.TIME_UNSET;
}
@Nullable
PrivFrame privFrame =
metadata.getFirstMatchingEntry(
PrivFrame.class, frame -> frame.owner.equals(HlsMediaChunk.PRIV_TIMESTAMP_FRAME_OWNER));
if (privFrame == null) {
return C.TIME_UNSET;
}
System.arraycopy(privFrame.privateData, 0, scratchId3Data.getData(), 0, 8 /* timestamp size */);
scratchId3Data.setPosition(0);
scratchId3Data.setLimit(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;
}
// Internal methods.
private static byte[] getEncryptionIvArray(String ivString) {
String trimmedIv;
if (Ascii.toLowerCase(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) {
checkNotNull(encryptionIv);
return new Aes128DataSource(dataSource, fullSegmentEncryptionKey, encryptionIv);
}
return dataSource;
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2020 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 androidx.media3.exoplayer.hls;
import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import java.io.IOException;
/** Extracts samples and track {@link Format Formats} from {@link HlsMediaChunk HlsMediaChunks}. */
@UnstableApi
public interface HlsMediaChunkExtractor {
/**
* Initializes the extractor with an {@link ExtractorOutput}. Called at most once.
*
* @param extractorOutput An {@link ExtractorOutput} to receive extracted data.
*/
void init(ExtractorOutput extractorOutput);
/**
* Extracts data read from a provided {@link ExtractorInput}. Must not be called before {@link
* #init(ExtractorOutput)}.
*
* <p>A single call to this method will block until some progress has been made, but will not
* block for longer than this. Hence each call will consume only a small amount of input data.
*
* <p>When this method throws an {@link IOException}, extraction may continue by providing an
* {@link ExtractorInput} with an unchanged {@link ExtractorInput#getPosition() read position} to
* a subsequent call to this method.
*
* @param extractorInput The input to read from.
* @return Whether there is any data left to extract. Returns false if the end of input has been
* reached.
* @throws IOException If an error occurred reading from or parsing the input.
*/
boolean read(ExtractorInput extractorInput) throws IOException;
/** Returns whether this is a packed audio extractor, as defined in RFC 8216, Section 3.4. */
boolean isPackedAudioExtractor();
/** Returns whether this instance can be used for extracting multiple continuous segments. */
boolean isReusable();
/**
* Returns a new instance for extracting the same type of media as this one. Can only be called on
* instances that are not {@link #isReusable() reusable}.
*/
HlsMediaChunkExtractor recreate();
/**
* Resets the sample parsing state.
*
* <p>Resetting the parsing state allows support for Fragmented MP4 EXT-X-I-FRAME-STREAM-INF
* segments. EXT-X-I-FRAME-STREAM-INF segments are truncated to include only a leading key frame.
* After parsing said keyframe, an extractor may reach an unexpected end of file. By resetting its
* state, we can continue feeding samples from the following segments to the extractor. See <a
* href="https://github.com/google/ExoPlayer/issues/7512">#7512</a> for context.
*/
void onTruncatedSegmentParsed();
}
@@ -0,0 +1,803 @@
/*
* 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 androidx.media3.exoplayer.hls;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.os.Looper;
import android.os.SystemClock;
import androidx.annotation.GuardedBy;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.StreamKey;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider;
import androidx.media3.exoplayer.drm.DrmSessionEventListener;
import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory;
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistTracker;
import androidx.media3.exoplayer.hls.playlist.FilteringHlsPlaylistParserFactory;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory;
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker;
import androidx.media3.exoplayer.source.BaseMediaSource;
import androidx.media3.exoplayer.source.CompositeSequenceableLoaderFactory;
import androidx.media3.exoplayer.source.DefaultCompositeSequenceableLoaderFactory;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.MediaSourceEventListener;
import androidx.media3.exoplayer.source.MediaSourceFactory;
import androidx.media3.exoplayer.source.SequenceableLoader;
import androidx.media3.exoplayer.source.SinglePeriodTimeline;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.CmcdConfiguration;
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.util.ReleasableExecutor;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.common.base.Supplier;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.List;
import java.util.Objects;
/** An HLS {@link MediaSource}. */
@UnstableApi
public final class HlsMediaSource extends BaseMediaSource
implements HlsPlaylistTracker.PrimaryPlaylistListener {
static {
MediaLibraryInfo.registerModule("media3.exoplayer.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)
@Target(TYPE_USE)
@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 EMSG metadata in HLS streams. */
public static final int METADATA_TYPE_EMSG = 3;
/** Factory for {@link HlsMediaSource}s. */
@SuppressWarnings("deprecation") // Implement deprecated type for backwards compatibility.
public static final class Factory implements MediaSourceFactory {
private final HlsDataSourceFactory hlsDataSourceFactory;
@Nullable private HlsExtractorFactory extractorFactory;
@Nullable private SubtitleParser.Factory subtitleParserFactoryOverride;
private boolean parseSubtitlesDuringExtraction;
private @C.VideoCodecFlags int codecsToParseWithinGopSampleDependencies;
private HlsPlaylistParserFactory playlistParserFactory;
private HlsPlaylistTracker.Factory playlistTrackerFactory;
private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
@Nullable private CmcdConfiguration.Factory cmcdConfigurationFactory;
private DrmSessionManagerProvider drmSessionManagerProvider;
private LoadErrorHandlingPolicy loadErrorHandlingPolicy;
@Nullable private Supplier<ReleasableExecutor> downloadExecutorSupplier;
private boolean allowChunklessPreparation;
private @MetadataType int metadataType;
private boolean useSessionKeys;
private long elapsedRealTimeOffsetMs;
private long timestampAdjusterInitializationTimeoutMs;
/**
* Creates a new factory for {@link HlsMediaSource}s.
*
* <p>The factory will use the following default components:
*
* <ul>
* <li>{@link DefaultDrmSessionManagerProvider}
* <li>{@link DefaultHlsPlaylistParserFactory}
* <li>{@link DefaultHlsPlaylistTracker#FACTORY}
* <li>{@link DefaultHlsExtractorFactory}
* <li>{@link DefaultLoadErrorHandlingPolicy}
* <li>{@link DefaultCompositeSequenceableLoaderFactory}
* </ul>
*
* @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.
*
* <p>The factory will use the following default components:
*
* <ul>
* <li>{@link DefaultDrmSessionManagerProvider}
* <li>{@link DefaultHlsPlaylistParserFactory}
* <li>{@link DefaultHlsPlaylistTracker#FACTORY}
* <li>{@link DefaultHlsExtractorFactory}
* <li>{@link DefaultLoadErrorHandlingPolicy}
* <li>{@link DefaultCompositeSequenceableLoaderFactory}
* </ul>
*
* @param hlsDataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for
* manifests, segments and keys.
*/
public Factory(HlsDataSourceFactory hlsDataSourceFactory) {
this.hlsDataSourceFactory = checkNotNull(hlsDataSourceFactory);
drmSessionManagerProvider = new DefaultDrmSessionManagerProvider();
playlistParserFactory = new DefaultHlsPlaylistParserFactory();
playlistTrackerFactory = DefaultHlsPlaylistTracker.FACTORY;
loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy();
compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory();
downloadExecutorSupplier = null;
metadataType = METADATA_TYPE_ID3;
elapsedRealTimeOffsetMs = C.TIME_UNSET;
allowChunklessPreparation = true;
codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 | C.VIDEO_CODEC_FLAG_H265;
experimentalParseSubtitlesDuringExtraction(true);
}
/**
* Sets the factory for {@link Extractor}s for the segments. The default value is {@link
* DefaultHlsExtractorFactory}.
*
* <p>Any values passed to {@link #setSubtitleParserFactory} or {@link
* #experimentalParseSubtitlesDuringExtraction} will be forwarded to the provided {@link
* HlsExtractorFactory} instance during {@link #createMediaSource}.
*
* @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the
* segments.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
public Factory setExtractorFactory(@Nullable HlsExtractorFactory extractorFactory) {
this.extractorFactory = extractorFactory;
return this;
}
@CanIgnoreReturnValue
@Override
public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
this.loadErrorHandlingPolicy =
checkNotNull(
loadErrorHandlingPolicy,
"MediaSource.Factory#setLoadErrorHandlingPolicy no longer handles null by"
+ " instantiating a new DefaultLoadErrorHandlingPolicy. Explicitly construct and"
+ " pass an instance in order to retain the old behavior.");
return this;
}
@CanIgnoreReturnValue
@Override
public Factory setSubtitleParserFactory(SubtitleParser.Factory subtitleParserFactory) {
this.subtitleParserFactoryOverride = subtitleParserFactory;
return this;
}
@Override
@Deprecated
@CanIgnoreReturnValue
public Factory experimentalParseSubtitlesDuringExtraction(
boolean parseSubtitlesDuringExtraction) {
this.parseSubtitlesDuringExtraction = parseSubtitlesDuringExtraction;
return this;
}
@Override
@CanIgnoreReturnValue
public Factory experimentalSetCodecsToParseWithinGopSampleDependencies(
@C.VideoCodecFlags int codecsToParseWithinGopSampleDependencies) {
this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies;
return this;
}
/**
* Sets the factory from which playlist parsers will be obtained.
*
* @param playlistParserFactory An {@link HlsPlaylistParserFactory}.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
public Factory setPlaylistParserFactory(HlsPlaylistParserFactory playlistParserFactory) {
this.playlistParserFactory =
checkNotNull(
playlistParserFactory,
"HlsMediaSource.Factory#setPlaylistParserFactory no longer handles null by"
+ " instantiating a new DefaultHlsPlaylistParserFactory. Explicitly"
+ " construct and pass an instance in order to retain the old behavior.");
return this;
}
/**
* Sets the {@link HlsPlaylistTracker} factory.
*
* @param playlistTrackerFactory A factory for {@link HlsPlaylistTracker} instances.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
public Factory setPlaylistTrackerFactory(HlsPlaylistTracker.Factory playlistTrackerFactory) {
this.playlistTrackerFactory =
checkNotNull(
playlistTrackerFactory,
"HlsMediaSource.Factory#setPlaylistTrackerFactory no longer handles null by"
+ " defaulting to DefaultHlsPlaylistTracker.FACTORY. Explicitly"
+ " pass a reference to this instance in order to retain the old behavior.");
return this;
}
/**
* Sets the factory to create composite {@link SequenceableLoader}s for when this media source
* loads data from multiple streams (video, audio etc...).
*
* @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.
*/
@CanIgnoreReturnValue
public Factory setCompositeSequenceableLoaderFactory(
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory) {
this.compositeSequenceableLoaderFactory =
checkNotNull(
compositeSequenceableLoaderFactory,
"HlsMediaSource.Factory#setCompositeSequenceableLoaderFactory no longer handles null"
+ " by instantiating a new DefaultCompositeSequenceableLoaderFactory. Explicitly"
+ " construct and pass an instance in order to retain the old behavior.");
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 multivariant
* playlist.
*
* @param allowChunklessPreparation Whether chunkless preparation is allowed.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
public Factory setAllowChunklessPreparation(boolean allowChunklessPreparation) {
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.
*/
@CanIgnoreReturnValue
public Factory setMetadataType(@MetadataType int metadataType) {
this.metadataType = metadataType;
return this;
}
/**
* Sets whether to use #EXT-X-SESSION-KEY tags provided in the multivariant playlist. If
* enabled, it's assumed that any single session key declared in the multivariant 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.
*/
@CanIgnoreReturnValue
public Factory setUseSessionKeys(boolean useSessionKeys) {
this.useSessionKeys = useSessionKeys;
return this;
}
@CanIgnoreReturnValue
@Override
public Factory setCmcdConfigurationFactory(CmcdConfiguration.Factory cmcdConfigurationFactory) {
this.cmcdConfigurationFactory = checkNotNull(cmcdConfigurationFactory);
return this;
}
@CanIgnoreReturnValue
@Override
public Factory setDrmSessionManagerProvider(
DrmSessionManagerProvider drmSessionManagerProvider) {
this.drmSessionManagerProvider =
checkNotNull(
drmSessionManagerProvider,
"MediaSource.Factory#setDrmSessionManagerProvider no longer handles null by"
+ " instantiating a new DefaultDrmSessionManagerProvider. Explicitly construct"
+ " and pass an instance in order to retain the old behavior.");
return this;
}
/**
* Sets the timeout for the loading thread to wait for the timestamp adjuster to initialize, in
* milliseconds.The default value is zero, which is interpreted as an infinite timeout.
*
* @param timestampAdjusterInitializationTimeoutMs The timeout in milliseconds. A timeout of
* zero is interpreted as an infinite timeout.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
public Factory setTimestampAdjusterInitializationTimeoutMs(
long timestampAdjusterInitializationTimeoutMs) {
this.timestampAdjusterInitializationTimeoutMs = timestampAdjusterInitializationTimeoutMs;
return this;
}
/**
* Sets the offset between {@link SystemClock#elapsedRealtime()} and the time since the Unix
* epoch. By default, is it set to {@link C#TIME_UNSET}.
*
* @param elapsedRealTimeOffsetMs The offset between {@link SystemClock#elapsedRealtime()} and
* the time since the Unix epoch, in milliseconds.
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
@VisibleForTesting
/* package */ Factory setElapsedRealTimeOffsetMs(long elapsedRealTimeOffsetMs) {
this.elapsedRealTimeOffsetMs = elapsedRealTimeOffsetMs;
return this;
}
@CanIgnoreReturnValue
@Override
public Factory setDownloadExecutor(Supplier<ReleasableExecutor> downloadExecutor) {
this.downloadExecutorSupplier = downloadExecutor;
return this;
}
/**
* Returns a new {@link HlsMediaSource} using the current parameters.
*
* @param mediaItem The {@link MediaItem}.
* @return The new {@link HlsMediaSource}.
* @throws NullPointerException if {@link MediaItem#localConfiguration} is {@code null}.
*/
@Override
public HlsMediaSource createMediaSource(MediaItem mediaItem) {
checkNotNull(mediaItem.localConfiguration);
if (extractorFactory == null) {
extractorFactory = new DefaultHlsExtractorFactory();
}
if (subtitleParserFactoryOverride != null) {
extractorFactory.setSubtitleParserFactory(subtitleParserFactoryOverride);
}
extractorFactory.experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction);
extractorFactory.experimentalSetCodecsToParseWithinGopSampleDependencies(
codecsToParseWithinGopSampleDependencies);
HlsExtractorFactory extractorFactory = this.extractorFactory;
HlsPlaylistParserFactory playlistParserFactory = this.playlistParserFactory;
List<StreamKey> streamKeys = mediaItem.localConfiguration.streamKeys;
if (!streamKeys.isEmpty()) {
playlistParserFactory =
new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys);
}
@Nullable
CmcdConfiguration cmcdConfiguration =
cmcdConfigurationFactory == null
? null
: cmcdConfigurationFactory.createCmcdConfiguration(mediaItem);
return new HlsMediaSource(
mediaItem,
hlsDataSourceFactory,
extractorFactory,
compositeSequenceableLoaderFactory,
cmcdConfiguration,
drmSessionManagerProvider.get(mediaItem),
loadErrorHandlingPolicy,
playlistTrackerFactory.createTracker(
hlsDataSourceFactory,
loadErrorHandlingPolicy,
playlistParserFactory,
cmcdConfiguration,
downloadExecutorSupplier),
elapsedRealTimeOffsetMs,
allowChunklessPreparation,
metadataType,
useSessionKeys,
timestampAdjusterInitializationTimeoutMs,
downloadExecutorSupplier);
}
@Override
public @C.ContentType int[] getSupportedTypes() {
return new int[] {C.CONTENT_TYPE_HLS};
}
}
private final HlsExtractorFactory extractorFactory;
private final HlsDataSourceFactory dataSourceFactory;
private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
@Nullable private final CmcdConfiguration cmcdConfiguration;
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;
private final long elapsedRealTimeOffsetMs;
private final long timestampAdjusterInitializationTimeoutMs;
@Nullable private final Supplier<ReleasableExecutor> downloadExecutorSupplier;
private MediaItem.LiveConfiguration liveConfiguration;
@Nullable private TransferListener mediaTransferListener;
@GuardedBy("this")
private MediaItem mediaItem;
private HlsMediaSource(
MediaItem mediaItem,
HlsDataSourceFactory dataSourceFactory,
HlsExtractorFactory extractorFactory,
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
@Nullable CmcdConfiguration cmcdConfiguration,
DrmSessionManager drmSessionManager,
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
HlsPlaylistTracker playlistTracker,
long elapsedRealTimeOffsetMs,
boolean allowChunklessPreparation,
@MetadataType int metadataType,
boolean useSessionKeys,
long timestampAdjusterInitializationTimeoutMs,
@Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier) {
this.mediaItem = mediaItem;
this.liveConfiguration = mediaItem.liveConfiguration;
this.dataSourceFactory = dataSourceFactory;
this.extractorFactory = extractorFactory;
this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
this.cmcdConfiguration = cmcdConfiguration;
this.drmSessionManager = drmSessionManager;
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
this.playlistTracker = playlistTracker;
this.elapsedRealTimeOffsetMs = elapsedRealTimeOffsetMs;
this.allowChunklessPreparation = allowChunklessPreparation;
this.metadataType = metadataType;
this.useSessionKeys = useSessionKeys;
this.timestampAdjusterInitializationTimeoutMs = timestampAdjusterInitializationTimeoutMs;
this.downloadExecutorSupplier = downloadExecutorSupplier;
}
@Override
public synchronized MediaItem getMediaItem() {
return mediaItem;
}
@Override
public boolean canUpdateMediaItem(MediaItem mediaItem) {
MediaItem existingMediaItem = getMediaItem();
MediaItem.LocalConfiguration existingConfiguration =
checkNotNull(existingMediaItem.localConfiguration);
@Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration;
return newConfiguration != null
&& newConfiguration.uri.equals(existingConfiguration.uri)
&& newConfiguration.streamKeys.equals(existingConfiguration.streamKeys)
&& Objects.equals(newConfiguration.drmConfiguration, existingConfiguration.drmConfiguration)
&& existingMediaItem.liveConfiguration.equals(mediaItem.liveConfiguration);
}
@Override
public synchronized void updateMediaItem(MediaItem mediaItem) {
this.mediaItem = mediaItem;
}
@Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
this.mediaTransferListener = mediaTransferListener;
drmSessionManager.setPlayer(
/* playbackLooper= */ checkNotNull(Looper.myLooper()), getPlayerId());
drmSessionManager.prepare();
MediaSourceEventListener.EventDispatcher eventDispatcher =
createEventDispatcher(/* mediaPeriodId= */ null);
playlistTracker.start(
checkNotNull(getMediaItem().localConfiguration).uri,
eventDispatcher,
/* primaryPlaylistListener= */ this,
getBandwidthMeter());
}
@Override
public void maybeThrowSourceInfoRefreshError() throws IOException {
playlistTracker.maybeThrowPrimaryPlaylistRefreshError();
}
@Override
public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher = createEventDispatcher(id);
DrmSessionEventListener.EventDispatcher drmEventDispatcher = createDrmEventDispatcher(id);
return new HlsMediaPeriod(
extractorFactory,
playlistTracker,
dataSourceFactory,
mediaTransferListener,
cmcdConfiguration,
drmSessionManager,
drmEventDispatcher,
loadErrorHandlingPolicy,
mediaSourceEventDispatcher,
allocator,
compositeSequenceableLoaderFactory,
allowChunklessPreparation,
metadataType,
useSessionKeys,
getPlayerId(),
timestampAdjusterInitializationTimeoutMs,
downloadExecutorSupplier);
}
@Override
public void releasePeriod(MediaPeriod mediaPeriod) {
((HlsMediaPeriod) mediaPeriod).release();
}
@Override
protected void releaseSourceInternal() {
playlistTracker.stop();
drmSessionManager.release();
}
@Override
public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist) {
long windowStartTimeMs =
mediaPlaylist.hasProgramDateTime ? Util.usToMs(mediaPlaylist.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 =
mediaPlaylist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT
|| mediaPlaylist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD
? windowStartTimeMs
: C.TIME_UNSET;
// The multivariant playlist is non-null because the first playlist has been fetched by now.
HlsManifest manifest =
new HlsManifest(checkNotNull(playlistTracker.getMultivariantPlaylist()), mediaPlaylist);
SinglePeriodTimeline timeline =
playlistTracker.isLive()
? createTimelineForLive(
mediaPlaylist, presentationStartTimeMs, windowStartTimeMs, manifest)
: createTimelineForOnDemand(
mediaPlaylist, presentationStartTimeMs, windowStartTimeMs, manifest);
refreshSourceInfo(timeline);
}
private SinglePeriodTimeline createTimelineForLive(
HlsMediaPlaylist playlist,
long presentationStartTimeMs,
long windowStartTimeMs,
HlsManifest manifest) {
long offsetFromInitialStartTimeUs =
playlist.startTimeUs - playlistTracker.getInitialStartTimeUs();
long periodDurationUs =
playlist.hasEndTag ? offsetFromInitialStartTimeUs + playlist.durationUs : C.TIME_UNSET;
long liveEdgeOffsetUs = getLiveEdgeOffsetUs(playlist);
long targetLiveOffsetUs;
if (liveConfiguration.targetOffsetMs != C.TIME_UNSET) {
// Media item has a defined target offset.
targetLiveOffsetUs = Util.msToUs(liveConfiguration.targetOffsetMs);
} else {
// Decide target offset from playlist.
targetLiveOffsetUs = getTargetLiveOffsetUs(playlist, liveEdgeOffsetUs);
}
// Ensure target live offset is within the live window and greater than the live edge offset.
targetLiveOffsetUs =
Util.constrainValue(
targetLiveOffsetUs, liveEdgeOffsetUs, playlist.durationUs + liveEdgeOffsetUs);
updateLiveConfiguration(playlist, targetLiveOffsetUs);
long windowDefaultStartPositionUs =
getLiveWindowDefaultStartPositionUs(playlist, liveEdgeOffsetUs);
boolean suppressPositionProjection =
playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT
&& playlist.hasPositiveStartOffset;
return new SinglePeriodTimeline(
presentationStartTimeMs,
windowStartTimeMs,
/* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
periodDurationUs,
/* windowDurationUs= */ playlist.durationUs,
/* windowPositionInPeriodUs= */ offsetFromInitialStartTimeUs,
windowDefaultStartPositionUs,
/* isSeekable= */ true,
/* isDynamic= */ !playlist.hasEndTag,
suppressPositionProjection,
manifest,
getMediaItem(),
liveConfiguration);
}
private SinglePeriodTimeline createTimelineForOnDemand(
HlsMediaPlaylist playlist,
long presentationStartTimeMs,
long windowStartTimeMs,
HlsManifest manifest) {
long windowDefaultStartPositionUs;
if (playlist.startOffsetUs == C.TIME_UNSET || playlist.segments.isEmpty()) {
windowDefaultStartPositionUs = 0;
} else {
if (playlist.preciseStart || playlist.startOffsetUs == playlist.durationUs) {
windowDefaultStartPositionUs = playlist.startOffsetUs;
} else {
windowDefaultStartPositionUs =
findClosestPrecedingSegment(playlist.segments, playlist.startOffsetUs)
.relativeStartTimeUs;
}
}
return new SinglePeriodTimeline(
presentationStartTimeMs,
windowStartTimeMs,
/* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
/* periodDurationUs= */ playlist.durationUs,
/* windowDurationUs= */ playlist.durationUs,
/* windowPositionInPeriodUs= */ 0,
windowDefaultStartPositionUs,
/* isSeekable= */ true,
/* isDynamic= */ false,
/* suppressPositionProjection= */ true,
manifest,
getMediaItem(),
/* liveConfiguration= */ null);
}
private long getLiveEdgeOffsetUs(HlsMediaPlaylist playlist) {
return playlist.hasProgramDateTime
? Util.msToUs(Util.getNowUnixTimeMs(elapsedRealTimeOffsetMs)) - playlist.getEndTimeUs()
: 0;
}
private long getLiveWindowDefaultStartPositionUs(
HlsMediaPlaylist playlist, long liveEdgeOffsetUs) {
long startPositionUs =
playlist.startOffsetUs != C.TIME_UNSET
? playlist.startOffsetUs
: playlist.durationUs
+ liveEdgeOffsetUs
- Util.msToUs(liveConfiguration.targetOffsetMs);
if (playlist.preciseStart) {
return startPositionUs;
}
@Nullable
HlsMediaPlaylist.Part part =
findClosestPrecedingIndependentPart(playlist.trailingParts, startPositionUs);
if (part != null) {
return part.relativeStartTimeUs;
}
if (playlist.segments.isEmpty()) {
return 0;
}
HlsMediaPlaylist.Segment segment =
findClosestPrecedingSegment(playlist.segments, startPositionUs);
part = findClosestPrecedingIndependentPart(segment.parts, startPositionUs);
if (part != null) {
return part.relativeStartTimeUs;
}
return segment.relativeStartTimeUs;
}
private void updateLiveConfiguration(HlsMediaPlaylist playlist, long targetLiveOffsetUs) {
MediaItem.LiveConfiguration mediaItemLiveConfiguration = getMediaItem().liveConfiguration;
boolean disableSpeedAdjustment =
mediaItemLiveConfiguration.minPlaybackSpeed == C.RATE_UNSET
&& mediaItemLiveConfiguration.maxPlaybackSpeed == C.RATE_UNSET
&& playlist.serverControl.holdBackUs == C.TIME_UNSET
&& playlist.serverControl.partHoldBackUs == C.TIME_UNSET;
liveConfiguration =
liveConfiguration
.buildUpon()
.setTargetOffsetMs(Util.usToMs(targetLiveOffsetUs))
.setMinPlaybackSpeed(disableSpeedAdjustment ? 1f : liveConfiguration.minPlaybackSpeed)
.setMaxPlaybackSpeed(disableSpeedAdjustment ? 1f : liveConfiguration.maxPlaybackSpeed)
.build();
}
/**
* Gets the target live offset, in microseconds, for a live playlist.
*
* <p>The target offset is derived by checking the following in this order:
*
* <ol>
* <li>The playlist defines a start offset.
* <li>The playlist defines a part hold back in server control and has part duration.
* <li>The playlist defines a hold back in server control.
* <li>Fallback to {@code 3 x target duration}.
* </ol>
*
* @param playlist The playlist.
* @param liveEdgeOffsetUs The current live edge offset.
* @return The selected target live offset, in microseconds.
*/
private static long getTargetLiveOffsetUs(HlsMediaPlaylist playlist, long liveEdgeOffsetUs) {
HlsMediaPlaylist.ServerControl serverControl = playlist.serverControl;
long targetOffsetUs;
if (playlist.startOffsetUs != C.TIME_UNSET) {
targetOffsetUs = playlist.durationUs - playlist.startOffsetUs;
} else if (serverControl.partHoldBackUs != C.TIME_UNSET
&& playlist.partTargetDurationUs != C.TIME_UNSET) {
// Select part hold back only if the playlist has a part target duration.
targetOffsetUs = serverControl.partHoldBackUs;
} else if (serverControl.holdBackUs != C.TIME_UNSET) {
targetOffsetUs = serverControl.holdBackUs;
} else {
// Fallback, see RFC 8216, Section 4.4.3.8.
targetOffsetUs = 3 * playlist.targetDurationUs;
}
return targetOffsetUs + liveEdgeOffsetUs;
}
@Nullable
private static HlsMediaPlaylist.Part findClosestPrecedingIndependentPart(
List<HlsMediaPlaylist.Part> parts, long positionUs) {
@Nullable HlsMediaPlaylist.Part closestPart = null;
for (int i = 0; i < parts.size(); i++) {
HlsMediaPlaylist.Part part = parts.get(i);
if (part.relativeStartTimeUs <= positionUs && part.isIndependent) {
closestPart = part;
} else if (part.relativeStartTimeUs > positionUs) {
break;
}
}
return closestPart;
}
/**
* Gets the segment that contains {@code positionUs}, or the last segment if the position is
* beyond the segments list.
*/
private static HlsMediaPlaylist.Segment findClosestPrecedingSegment(
List<HlsMediaPlaylist.Segment> segments, long positionUs) {
int segmentIndex =
Util.binarySearchFloor(
segments, positionUs, /* inclusive= */ true, /* stayInBounds= */ true);
return segments.get(segmentIndex);
}
}
@@ -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 androidx.media3.exoplayer.hls;
import static com.google.common.base.Preconditions.checkArgument;
import androidx.media3.common.C;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.exoplayer.source.SampleStream;
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() {
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, @ReadFlags int readFlags) {
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, readFlags)
: 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;
}
}
@@ -0,0 +1,180 @@
/*
* 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 androidx.media3.exoplayer.hls;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.util.UnstableApi;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Holds metadata associated to an HLS media track. */
@UnstableApi
public final class HlsTrackMetadataEntry implements Metadata.Entry {
/** Holds attributes defined in an EXT-X-STREAM-INF tag. */
public static final class VariantInfo {
/**
* The average bitrate as declared by the AVERAGE-BANDWIDTH attribute of the EXT-X-STREAM-INF
* tag, or {@link Format#NO_VALUE} if the attribute is not declared.
*/
public final int averageBitrate;
/** The peak bitrate as declared by the BANDWIDTH attribute of the EXT-X-STREAM-INF tag. */
public final int peakBitrate;
/**
* 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 averageBitrate See {@link #averageBitrate}.
* @param peakBitrate See {@link #peakBitrate}.
* @param videoGroupId See {@link #videoGroupId}.
* @param audioGroupId See {@link #audioGroupId}.
* @param subtitleGroupId See {@link #subtitleGroupId}.
* @param captionGroupId See {@link #captionGroupId}.
*/
public VariantInfo(
int averageBitrate,
int peakBitrate,
@Nullable String videoGroupId,
@Nullable String audioGroupId,
@Nullable String subtitleGroupId,
@Nullable String captionGroupId) {
this.averageBitrate = averageBitrate;
this.peakBitrate = peakBitrate;
this.videoGroupId = videoGroupId;
this.audioGroupId = audioGroupId;
this.subtitleGroupId = subtitleGroupId;
this.captionGroupId = captionGroupId;
}
@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 averageBitrate == that.averageBitrate
&& peakBitrate == that.peakBitrate
&& 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 = averageBitrate;
result = 31 * result + peakBitrate;
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;
}
}
/**
* 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));
}
@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;
}
}
@@ -0,0 +1,311 @@
/*
* Copyright 2020 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 androidx.media3.exoplayer.hls;
import static android.media.MediaParser.PARAMETER_TS_IGNORE_AAC_STREAM;
import static android.media.MediaParser.PARAMETER_TS_IGNORE_AVC_STREAM;
import static android.media.MediaParser.PARAMETER_TS_IGNORE_SPLICE_INFO_STREAM;
import static android.media.MediaParser.PARAMETER_TS_MODE;
import static android.os.Build.VERSION.SDK_INT;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_EXPOSE_CAPTION_FORMATS;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_IGNORE_TIMESTAMP_OFFSET;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_IN_BAND_CRYPTO_INFO;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS;
import static com.google.common.base.Preconditions.checkState;
import android.annotation.SuppressLint;
import android.media.MediaFormat;
import android.media.MediaParser;
import android.media.MediaParser.OutputConsumer;
import android.media.MediaParser.SeekPoint;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.media3.common.FileTypes;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.source.mediaparser.InputReaderAdapterV30;
import androidx.media3.exoplayer.source.mediaparser.MediaParserUtil;
import androidx.media3.exoplayer.source.mediaparser.OutputConsumerAdapterV30;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
/** {@link HlsMediaChunkExtractor} implemented on top of the platform's {@link MediaParser}. */
@RequiresApi(30)
@UnstableApi
public final class MediaParserHlsMediaChunkExtractor implements HlsMediaChunkExtractor {
/**
* {@link HlsExtractorFactory} implementation that produces {@link
* MediaParserHlsMediaChunkExtractor} for all container formats except WebVTT, for which a {@link
* BundledHlsMediaChunkExtractor} is returned.
*/
public static final HlsExtractorFactory FACTORY =
(uri,
format,
muxedCaptionFormats,
timestampAdjuster,
responseHeaders,
sniffingExtractorInput,
playerId) -> {
if (FileTypes.inferFileTypeFromMimeType(format.sampleMimeType) == FileTypes.WEBVTT) {
// The segment contains WebVTT. MediaParser does not support WebVTT parsing, so we use the
// bundled extractor.
return new BundledHlsMediaChunkExtractor(
new WebvttExtractor(
format.language,
timestampAdjuster,
SubtitleParser.Factory.UNSUPPORTED,
/* parseSubtitlesDuringExtraction= */ false),
format,
timestampAdjuster);
}
boolean overrideInBandCaptionDeclarations = muxedCaptionFormats != null;
ImmutableList.Builder<MediaFormat> muxedCaptionMediaFormatsBuilder =
ImmutableList.builder();
if (muxedCaptionFormats != null) {
// The manifest contains captions declarations. We use those to determine which captions
// will be exposed by MediaParser.
for (int i = 0; i < muxedCaptionFormats.size(); i++) {
muxedCaptionMediaFormatsBuilder.add(
MediaParserUtil.toCaptionsMediaFormat(muxedCaptionFormats.get(i)));
}
} else {
// The manifest does not declare any captions in the stream. Imitate the default HLS
// extractor factory and declare a 608 track by default.
muxedCaptionMediaFormatsBuilder.add(
MediaParserUtil.toCaptionsMediaFormat(
new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build()));
}
ImmutableList<MediaFormat> muxedCaptionMediaFormats =
muxedCaptionMediaFormatsBuilder.build();
// TODO: Factor out code for optimizing the sniffing order across both factories.
OutputConsumerAdapterV30 outputConsumerAdapter = new OutputConsumerAdapterV30();
outputConsumerAdapter.setMuxedCaptionFormats(
muxedCaptionFormats != null ? muxedCaptionFormats : ImmutableList.of());
outputConsumerAdapter.setTimestampAdjuster(timestampAdjuster);
MediaParser mediaParser =
createMediaParserInstance(
outputConsumerAdapter,
format,
overrideInBandCaptionDeclarations,
muxedCaptionMediaFormats,
playerId,
MediaParser.PARSER_NAME_FMP4,
MediaParser.PARSER_NAME_AC3,
MediaParser.PARSER_NAME_AC4,
MediaParser.PARSER_NAME_ADTS,
MediaParser.PARSER_NAME_MP3,
MediaParser.PARSER_NAME_TS);
PeekingInputReader peekingInputReader = new PeekingInputReader(sniffingExtractorInput);
// The chunk extractor constructor requires an instance with a known parser name, so we
// advance once for MediaParser to sniff the content.
mediaParser.advance(peekingInputReader);
outputConsumerAdapter.setSelectedParserName(mediaParser.getParserName());
return new MediaParserHlsMediaChunkExtractor(
mediaParser,
outputConsumerAdapter,
format,
overrideInBandCaptionDeclarations,
muxedCaptionMediaFormats,
/* leadingBytesToSkip= */ peekingInputReader.totalPeekedBytes,
playerId);
};
private final OutputConsumerAdapterV30 outputConsumerAdapter;
private final InputReaderAdapterV30 inputReaderAdapter;
private final MediaParser mediaParser;
private final Format format;
private final boolean overrideInBandCaptionDeclarations;
private final ImmutableList<MediaFormat> muxedCaptionMediaFormats;
private final PlayerId playerId;
private int pendingSkipBytes;
/**
* Creates a new instance.
*
* @param mediaParser The {@link MediaParser} instance to use for extraction of segments. The
* provided instance must have completed sniffing, or must have been created by name.
* @param outputConsumerAdapter The {@link OutputConsumerAdapterV30} with which {@code
* mediaParser} was created.
* @param format The {@link Format} associated with the segment.
* @param overrideInBandCaptionDeclarations Whether to ignore any in-band caption track
* declarations in favor of using the {@code muxedCaptionMediaFormats} instead. If false,
* caption declarations found in the extracted media will be used, causing {@code
* muxedCaptionMediaFormats} to be ignored instead.
* @param muxedCaptionMediaFormats The list of in-band caption {@link MediaFormat MediaFormats}
* that {@link MediaParser} should expose.
* @param leadingBytesToSkip The number of bytes to skip from the start of the input before
* starting extraction.
* @param playerId The {@link PlayerId} of the player using this chunk extractor.
*/
public MediaParserHlsMediaChunkExtractor(
MediaParser mediaParser,
OutputConsumerAdapterV30 outputConsumerAdapter,
Format format,
boolean overrideInBandCaptionDeclarations,
ImmutableList<MediaFormat> muxedCaptionMediaFormats,
int leadingBytesToSkip,
PlayerId playerId) {
this.mediaParser = mediaParser;
this.outputConsumerAdapter = outputConsumerAdapter;
this.overrideInBandCaptionDeclarations = overrideInBandCaptionDeclarations;
this.muxedCaptionMediaFormats = muxedCaptionMediaFormats;
this.format = format;
this.playerId = playerId;
pendingSkipBytes = leadingBytesToSkip;
inputReaderAdapter = new InputReaderAdapterV30();
}
// ChunkExtractor implementation.
@Override
public void init(ExtractorOutput extractorOutput) {
outputConsumerAdapter.setExtractorOutput(extractorOutput);
}
@Override
public boolean read(ExtractorInput extractorInput) throws IOException {
extractorInput.skipFully(pendingSkipBytes);
pendingSkipBytes = 0;
inputReaderAdapter.setDataReader(extractorInput, extractorInput.getLength());
return mediaParser.advance(inputReaderAdapter);
}
@Override
public boolean isPackedAudioExtractor() {
String parserName = mediaParser.getParserName();
return MediaParser.PARSER_NAME_AC3.equals(parserName)
|| MediaParser.PARSER_NAME_AC4.equals(parserName)
|| MediaParser.PARSER_NAME_ADTS.equals(parserName)
|| MediaParser.PARSER_NAME_MP3.equals(parserName);
}
@Override
public boolean isReusable() {
String parserName = mediaParser.getParserName();
return MediaParser.PARSER_NAME_FMP4.equals(parserName)
|| MediaParser.PARSER_NAME_TS.equals(parserName);
}
@Override
public HlsMediaChunkExtractor recreate() {
checkState(!isReusable());
return new MediaParserHlsMediaChunkExtractor(
createMediaParserInstance(
outputConsumerAdapter,
format,
overrideInBandCaptionDeclarations,
muxedCaptionMediaFormats,
playerId,
mediaParser.getParserName()),
outputConsumerAdapter,
format,
overrideInBandCaptionDeclarations,
muxedCaptionMediaFormats,
/* leadingBytesToSkip= */ 0,
playerId);
}
@Override
public void onTruncatedSegmentParsed() {
mediaParser.seek(SeekPoint.START);
}
// Allow constants that are not part of the public MediaParser API.
@SuppressLint({"WrongConstant"})
private static MediaParser createMediaParserInstance(
OutputConsumer outputConsumer,
Format format,
boolean overrideInBandCaptionDeclarations,
ImmutableList<MediaFormat> muxedCaptionMediaFormats,
PlayerId playerId,
String... parserNames) {
MediaParser mediaParser =
parserNames.length == 1
? MediaParser.createByName(parserNames[0], outputConsumer)
: MediaParser.create(outputConsumer, parserNames);
mediaParser.setParameter(PARAMETER_EXPOSE_CAPTION_FORMATS, muxedCaptionMediaFormats);
mediaParser.setParameter(
PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS, overrideInBandCaptionDeclarations);
mediaParser.setParameter(PARAMETER_IN_BAND_CRYPTO_INFO, true);
mediaParser.setParameter(PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE, true);
mediaParser.setParameter(PARAMETER_IGNORE_TIMESTAMP_OFFSET, true);
mediaParser.setParameter(PARAMETER_TS_IGNORE_SPLICE_INFO_STREAM, true);
mediaParser.setParameter(PARAMETER_TS_MODE, "hls");
@Nullable 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))) {
mediaParser.setParameter(PARAMETER_TS_IGNORE_AAC_STREAM, true);
}
if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) {
mediaParser.setParameter(PARAMETER_TS_IGNORE_AVC_STREAM, true);
}
}
if (SDK_INT >= 31) {
MediaParserUtil.setLogSessionIdOnMediaParser(mediaParser, playerId);
}
return mediaParser;
}
private static final class PeekingInputReader implements MediaParser.SeekableInputReader {
private final ExtractorInput extractorInput;
private int totalPeekedBytes;
private PeekingInputReader(ExtractorInput extractorInput) {
this.extractorInput = extractorInput;
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
int peekedBytes = extractorInput.peek(buffer, offset, readLength);
totalPeekedBytes += peekedBytes;
return peekedBytes;
}
@Override
public long getPosition() {
return extractorInput.getPeekPosition();
}
@Override
public long getLength() {
return extractorInput.getLength();
}
@Override
public void seekToPosition(long position) {
// Seeking is not allowed when sniffing the content.
throw new UnsupportedOperationException();
}
}
}
@@ -0,0 +1,34 @@
/*
* 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 androidx.media3.exoplayer.hls;
import androidx.annotation.Nullable;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.source.SampleQueue;
import java.io.IOException;
/** Thrown when it is not possible to map a {@link TrackGroup} to a {@link SampleQueue}. */
@UnstableApi
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 + ".");
}
}
@@ -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 androidx.media3.exoplayer.hls;
import static androidx.media3.common.util.TimestampAdjuster.MODE_SHARED;
import android.util.SparseArray;
import androidx.annotation.Nullable;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UnstableApi;
/** Provides {@link TimestampAdjuster} instances for use during HLS playbacks. */
@UnstableApi
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) {
@Nullable TimestampAdjuster adjuster = timestampAdjusters.get(discontinuitySequence);
if (adjuster == null) {
adjuster = new TimestampAdjuster(MODE_SHARED);
timestampAdjusters.put(discontinuitySequence, adjuster);
}
return adjuster;
}
/** Resets the provider. */
public void reset() {
timestampAdjusters.clear();
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2020 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 androidx.media3.exoplayer.hls;
import androidx.media3.common.C;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.source.SampleQueue;
import androidx.media3.exoplayer.source.chunk.MediaChunk;
import java.io.IOException;
/**
* Thrown when an attempt is made to write a sample to a {@link SampleQueue} whose timestamp is
* inconsistent with the chunk from which it originates.
*/
/* package */ final class UnexpectedSampleTimestampException extends IOException {
/** The {@link MediaChunk} that contained the rejected sample. */
public final MediaChunk mediaChunk;
/**
* The timestamp of the last sample that was loaded from {@link #mediaChunk} and successfully
* written to the {@link SampleQueue}, in microseconds. {@link C#TIME_UNSET} if the first sample
* in the chunk was rejected.
*/
public final long lastAcceptedSampleTimeUs;
/** The timestamp of the rejected sample, in microseconds. */
public final long rejectedSampleTimeUs;
/**
* Constructs an instance.
*
* @param mediaChunk The {@link MediaChunk} with the unexpected sample timestamp.
* @param lastAcceptedSampleTimeUs The timestamp of the last sample that was loaded from the chunk
* and successfully written to the {@link SampleQueue}, in microseconds. {@link C#TIME_UNSET}
* if the first sample in the chunk was rejected.
* @param rejectedSampleTimeUs The timestamp of the rejected sample, in microseconds.
*/
public UnexpectedSampleTimestampException(
MediaChunk mediaChunk, long lastAcceptedSampleTimeUs, long rejectedSampleTimeUs) {
super(
"Unexpected sample timestamp: "
+ Util.usToMs(rejectedSampleTimeUs)
+ " in chunk ["
+ mediaChunk.startTimeUs
+ ", "
+ mediaChunk.endTimeUs
+ "]");
this.mediaChunk = mediaChunk;
this.lastAcceptedSampleTimeUs = lastAcceptedSampleTimeUs;
this.rejectedSampleTimeUs = rejectedSampleTimeUs;
}
}
@@ -0,0 +1,234 @@
/*
* 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 androidx.media3.exoplayer.hls;
import static com.google.common.base.Preconditions.checkNotNull;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.TrackOutput;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput;
import androidx.media3.extractor.text.webvtt.WebvttParserUtil;
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.
*/
@UnstableApi
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 final SubtitleParser.Factory subtitleParserFactory;
private final boolean parseSubtitlesDuringExtraction;
private @MonotonicNonNull ExtractorOutput output;
private byte[] sampleData;
private int sampleSize;
/**
* @deprecated Use {@link #WebvttExtractor(String, TimestampAdjuster, SubtitleParser.Factory,
* boolean)} instead.
*/
@Deprecated
public WebvttExtractor(@Nullable String language, TimestampAdjuster timestampAdjuster) {
this(
language,
timestampAdjuster,
SubtitleParser.Factory.UNSUPPORTED,
/* parseSubtitlesDuringExtraction= */ false);
}
public WebvttExtractor(
@Nullable String language,
TimestampAdjuster timestampAdjuster,
SubtitleParser.Factory subtitleParserFactory,
boolean parseSubtitlesDuringExtraction) {
this.language = language;
this.timestampAdjuster = timestampAdjuster;
this.sampleDataWrapper = new ParsableByteArray();
sampleData = new byte[1024];
this.subtitleParserFactory = subtitleParserFactory;
this.parseSubtitlesDuringExtraction = parseSubtitlesDuringExtraction;
}
// Extractor implementation.
@Override
public boolean sniff(ExtractorInput input) throws IOException {
// 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 =
parseSubtitlesDuringExtraction
? new SubtitleTranscodingExtractorOutput(output, subtitleParserFactory)
: output;
this.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 {
// output == null suggests init() hasn't been called
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 ParserException.createForMalformedContainer(
"X-TIMESTAMP-MAP doesn't contain local timestamp: " + line, /* cause= */ null);
}
Matcher mediaTimestampMatcher = MEDIA_TIMESTAMP.matcher(line);
if (!mediaTimestampMatcher.find()) {
throw ParserException.createForMalformedContainer(
"X-TIMESTAMP-MAP doesn't contain media timestamp: " + line, /* cause= */ null);
}
vttTimestampUs =
WebvttParserUtil.parseTimestampUs(checkNotNull(localTimestampMatcher.group(1)));
tsTimestampUs =
TimestampAdjuster.ptsToUs(Long.parseLong(checkNotNull(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(checkNotNull(cueHeaderMatcher.group(1)));
long sampleTimeUs =
timestampAdjuster.adjustTsTimestamp(
TimestampAdjuster.usToWrappedPts(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(
new Format.Builder()
.setSampleMimeType(MimeTypes.TEXT_VTT)
.setLanguage(language)
.setSubsampleOffsetUs(subsampleOffsetUs)
.build());
output.endTracks();
return trackOutput;
}
}
@@ -0,0 +1,305 @@
/*
* 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 androidx.media3.exoplayer.hls.offline;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.UriUtil;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.cache.CacheDataSource;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist;
import androidx.media3.exoplayer.hls.playlist.HlsPlaylist;
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser;
import androidx.media3.exoplayer.offline.SegmentDownloader;
import androidx.media3.exoplayer.upstream.ParsingLoadable.Parser;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.Executor;
/**
* A downloader for HLS streams.
*
* <p>Example usage:
*
* <pre>{@code
* SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
* CacheDataSource.Factory cacheDataSourceFactory =
* new CacheDataSource.Factory()
* .setCache(cache)
* .setUpstreamDataSourceFactory(new DefaultHttpDataSource.Factory());
* // Create a downloader for the first variant in a multivariant playlist.
* HlsDownloader hlsDownloader =
* new HlsDownloader.Factory(cacheDataSourceFactory)
* .create(new MediaItem.Builder()
* .setUri(playlistUri)
* .setStreamKeys(
* ImmutableList.of(
* new StreamKey(HlsMultivariantPlaylist.GROUP_INDEX_VARIANT, 0)))
* .build());
* // Perform the download.
* hlsDownloader.download(progressListener);
* // Use the downloaded data for playback.
* HlsMediaSource mediaSource =
* new HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
* }</pre>
*/
@UnstableApi
public final class HlsDownloader extends SegmentDownloader<HlsPlaylist> {
/** A factory for {@linkplain HlsDownloader HLS downloaders}. */
public static final class Factory extends BaseFactory<HlsPlaylist> {
/**
* Creates a factory for {@link HlsDownloader}.
*
* @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
* download will be written.
*/
public Factory(CacheDataSource.Factory cacheDataSourceFactory) {
super(cacheDataSourceFactory, new HlsPlaylistParser());
}
/**
* Sets a parser for HLS playlists.
*
* @return This factory, for convenience.
*/
@CanIgnoreReturnValue
public Factory setManifestParser(HlsPlaylistParser manifestParser) {
this.manifestParser = manifestParser;
return this;
}
/**
* Sets the {@link Executor} used to make requests for the media being downloaded. Providing an
* {@link Executor} that uses multiple threads will speed up the download by allowing parts of
* it to be executed in parallel.
*
* @return This factory, for convenience.
*/
@Override
@CanIgnoreReturnValue
public Factory setExecutor(Executor executor) {
super.setExecutor(executor);
return this;
}
/**
* Sets the maximum difference of the start time of two segments, up to which the segments (of
* the same URI) should be merged into a single download segment, in milliseconds.
*
* @return This factory, for convenience.
*/
@Override
@CanIgnoreReturnValue
public Factory setMaxMergedSegmentStartTimeDiffMs(long maxMergedSegmentStartTimeDiffMs) {
super.setMaxMergedSegmentStartTimeDiffMs(maxMergedSegmentStartTimeDiffMs);
return this;
}
/**
* Sets the start position in microseconds that the download should start from.
*
* @return This factory, for convenience.
*/
@Override
@CanIgnoreReturnValue
public Factory setStartPositionUs(long startPositionUs) {
super.setStartPositionUs(startPositionUs);
return this;
}
/**
* Sets the duration in microseconds from the {@code startPositionUs} to be downloaded, or
* {@link C#TIME_UNSET} if the media should be downloaded to the end.
*
* @return This factory, for convenience.
*/
@Override
@CanIgnoreReturnValue
public Factory setDurationUs(long durationUs) {
super.setDurationUs(durationUs);
return this;
}
/** Creates {@linkplain HlsDownloader HLS downloaders}. */
@Override
public HlsDownloader create(MediaItem mediaItem) {
return new HlsDownloader(
mediaItem,
manifestParser,
cacheDataSourceFactory,
executor,
maxMergedSegmentStartTimeDiffMs,
startPositionUs,
durationUs);
}
}
private final Parser<HlsPlaylist> manifestParser;
/**
* @deprecated Use {@link HlsDownloader.Factory#create(MediaItem)} instead.
*/
@Deprecated
public HlsDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) {
this(mediaItem, cacheDataSourceFactory, Runnable::run);
}
/**
* @deprecated Use {@link HlsDownloader.Factory#create(MediaItem)} instead.
*/
@Deprecated
public HlsDownloader(
MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) {
this(
mediaItem,
new HlsPlaylistParser(),
cacheDataSourceFactory,
executor,
DEFAULT_MAX_MERGED_SEGMENT_START_TIME_DIFF_MS,
/* startPositionUs= */ 0,
/* durationUs= */ C.TIME_UNSET);
}
/**
* Creates a new instance.
*
* @param mediaItem The {@link MediaItem} to be downloaded.
* @param manifestParser A parser for HLS playlists.
* @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
* download will be written.
* @param executor An {@link Executor} used to make requests for the media being downloaded.
* Providing an {@link Executor} that uses multiple threads will speed up the download by
* allowing parts of it to be executed in parallel.
* @param maxMergedSegmentStartTimeDiffMs The maximum difference of the start time of two
* segments, up to which the segments (of the same URI) should be merged into a single
* download segment, in milliseconds.
* @param startPositionUs The start position in microseconds that the download should start from.
* @param durationUs The duration in microseconds from the {@code startPositionUs} to be
* downloaded, or {@link C#TIME_UNSET} if the media should be downloaded to the end.
*/
private HlsDownloader(
MediaItem mediaItem,
Parser<HlsPlaylist> manifestParser,
CacheDataSource.Factory cacheDataSourceFactory,
Executor executor,
long maxMergedSegmentStartTimeDiffMs,
long startPositionUs,
long durationUs) {
super(
mediaItem,
manifestParser,
cacheDataSourceFactory,
executor,
maxMergedSegmentStartTimeDiffMs,
startPositionUs,
durationUs);
this.manifestParser = manifestParser;
}
@Override
protected List<Segment> getSegments(DataSource dataSource, HlsPlaylist manifest, boolean removing)
throws IOException, InterruptedException {
ArrayList<DataSpec> mediaPlaylistDataSpecs = new ArrayList<>();
HlsMultivariantPlaylist multivariantPlaylist = HlsMultivariantPlaylist.EMPTY;
if (manifest instanceof HlsMultivariantPlaylist) {
multivariantPlaylist = (HlsMultivariantPlaylist) manifest;
addMediaPlaylistDataSpecs(multivariantPlaylist.mediaPlaylistUrls, mediaPlaylistDataSpecs);
} else {
mediaPlaylistDataSpecs.add(
SegmentDownloader.getCompressibleDataSpec(Uri.parse(manifest.baseUri)));
}
Parser<HlsPlaylist> mediaPlaylistParser =
!multivariantPlaylist.variableDefinitions.isEmpty()
? new HlsPlaylistParser(multivariantPlaylist, /* previousMediaPlaylist= */ null)
: manifestParser;
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)
getManifest(dataSource, mediaPlaylistParser, mediaPlaylistDataSpec, removing);
} catch (IOException e) {
if (!removing) {
throw e;
}
// Generating an incomplete segment list is allowed. Advance to the next media playlist.
continue;
}
@Nullable HlsMediaPlaylist.Segment lastInitSegment = null;
List<HlsMediaPlaylist.Segment> hlsSegments = mediaPlaylist.segments;
long startPositionUs = removing ? 0 : this.startPositionUs;
long durationUs = removing ? C.TIME_UNSET : this.durationUs;
for (int i = 0; i < hlsSegments.size(); i++) {
HlsMediaPlaylist.Segment segment = hlsSegments.get(i);
long segmentStartTimeUs = mediaPlaylist.startTimeUs + segment.relativeStartTimeUs;
if (segmentStartTimeUs + segment.durationUs <= startPositionUs) {
// The current segment is before the start position.
continue;
}
if (durationUs != C.TIME_UNSET && segmentStartTimeUs >= startPositionUs + durationUs) {
// The current segment is after the end position.
break;
}
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 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);
out.add(new Segment(startTimeUs, dataSpec));
}
}
@@ -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 androidx.media3.exoplayer.hls.offline;
import androidx.media3.common.util.NonNullApi;
@@ -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 androidx.media3.exoplayer.hls;
import androidx.media3.common.util.NonNullApi;
@@ -0,0 +1,37 @@
/*
* 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 androidx.media3.exoplayer.hls.playlist;
import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
/** Default implementation for {@link HlsPlaylistParserFactory}. */
@UnstableApi
public final class DefaultHlsPlaylistParserFactory implements HlsPlaylistParserFactory {
@Override
public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() {
return new HlsPlaylistParser();
}
@Override
public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(
HlsMultivariantPlaylist multivariantPlaylist,
@Nullable HlsMediaPlaylist previousMediaPlaylist) {
return new HlsPlaylistParser(multivariantPlaylist, previousMediaPlaylist);
}
}
@@ -0,0 +1,60 @@
/*
* 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 androidx.media3.exoplayer.hls.playlist;
import androidx.annotation.Nullable;
import androidx.media3.common.StreamKey;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.offline.FilteringManifestParser;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import java.util.List;
/**
* A {@link HlsPlaylistParserFactory} that includes only the streams identified by the given stream
* keys.
*/
@UnstableApi
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(
HlsMultivariantPlaylist multivariantPlaylist,
@Nullable HlsMediaPlaylist previousMediaPlaylist) {
return new FilteringManifestParser<>(
hlsPlaylistParserFactory.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist),
streamKeys);
}
}
@@ -0,0 +1,395 @@
/*
* 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 androidx.media3.exoplayer.hls.playlist;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.DrmInitData;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.StreamKey;
import androidx.media3.common.util.UnstableApi;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/** Represents an HLS multivariant playlist. */
@UnstableApi
public final class HlsMultivariantPlaylist extends HlsPlaylist {
/** Represents an empty multivariant playlist, from which no attributes can be inherited. */
public static final HlsMultivariantPlaylist EMPTY =
new HlsMultivariantPlaylist(
/* 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(),
/* contentSteeringInfo= */ null);
// 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 multivariant 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;
/** The identifier of the pathway that this variant belongs, or {@code null}. */
@Nullable public final String pathwayId;
/** The stable identifier for this variant, or {@code null}. */
@Nullable public final String stableVariantId;
/**
* @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}.
* @param pathwayId See {@link #pathwayId}.
* @param stableVariantId See {@link #stableVariantId}.
*/
public Variant(
Uri url,
Format format,
@Nullable String videoGroupId,
@Nullable String audioGroupId,
@Nullable String subtitleGroupId,
@Nullable String captionGroupId,
@Nullable String pathwayId,
@Nullable String stableVariantId) {
this.url = url;
this.format = format;
this.videoGroupId = videoGroupId;
this.audioGroupId = audioGroupId;
this.subtitleGroupId = subtitleGroupId;
this.captionGroupId = captionGroupId;
this.pathwayId = pathwayId;
this.stableVariantId = stableVariantId;
}
/**
* 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 =
new Format.Builder().setId("0").setContainerMimeType(MimeTypes.APPLICATION_M3U8).build();
return new Variant(
url,
format,
/* videoGroupId= */ null,
/* audioGroupId= */ null,
/* subtitleGroupId= */ null,
/* captionGroupId= */ null,
/* pathwayId= */ null,
/* stableVariantId= */ 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,
pathwayId,
stableVariantId);
}
}
/** A rendition (i.e. an #EXT-X-MEDIA tag) in a multivariant 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;
/** The stable identifier for this rendition, or {@code null}. * */
@Nullable public final String stableRenditionId;
/**
* @param url See {@link #url}.
* @param format See {@link #format}.
* @param groupId See {@link #groupId}.
* @param name See {@link #name}.
* @param stableRenditionId See {@link #stableRenditionId}.
*/
public Rendition(
@Nullable Uri url,
Format format,
String groupId,
String name,
@Nullable String stableRenditionId) {
this.url = url;
this.format = format;
this.groupId = groupId;
this.name = name;
this.stableRenditionId = stableRenditionId;
}
}
/** Content Steering information derived from an #EXT-X-CONTENT-STEERING tag. */
public static final class ContentSteeringInfo {
/** A URI to a Steering Manifest. */
public final Uri serverUri;
/**
* The ID of a pathway that must be used until the initial Steering Manifest has been obtained,
* or {@code null} which suggests that any available pathway can be used until the initial
* Steering Manifest has been obtained.
*/
@Nullable public final String pathwayId;
/**
* Creates a new instance.
*
* @param serverUri See {@link #serverUri}.
* @param pathwayId See {@link #pathwayId}.
*/
public ContentSteeringInfo(Uri serverUri, @Nullable String pathwayId) {
this.serverUri = serverUri;
this.pathwayId = pathwayId;
}
}
/** 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;
/** Content Steering information derived from #EXT-X-CONTENT-STEERING tag. */
@Nullable public final ContentSteeringInfo contentSteeringInfo;
/**
* @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}.
* @param contentSteeringInfo See {@link #contentSteeringInfo}.
*/
public HlsMultivariantPlaylist(
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,
@Nullable ContentSteeringInfo contentSteeringInfo) {
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);
this.contentSteeringInfo = contentSteeringInfo;
}
@Override
public HlsMultivariantPlaylist copy(List<StreamKey> streamKeys) {
return new HlsMultivariantPlaylist(
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,
contentSteeringInfo);
}
/**
* Creates a playlist with a single variant.
*
* @param variantUrl The url of the single variant.
* @return A multivariant playlist with a single variant for the provided url.
*/
public static HlsMultivariantPlaylist createSingleVariantMultivariantPlaylist(String variantUrl) {
List<Variant> variant =
Collections.singletonList(Variant.createMediaPlaylistVariantUrl(Uri.parse(variantUrl)));
return new HlsMultivariantPlaylist(
/* 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(),
/* contentSteeringInfo= */ null);
}
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 renditions 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.streamIndex == i) {
copiedStreams.add(stream);
break;
}
}
}
return copiedStreams;
}
}
@@ -0,0 +1,49 @@
/*
* 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 androidx.media3.exoplayer.hls.playlist;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.offline.FilterableManifest;
import java.util.Collections;
import java.util.List;
/** Represents an HLS playlist. */
@UnstableApi
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;
}
}
@@ -0,0 +1,46 @@
/*
* 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 androidx.media3.exoplayer.hls.playlist;
import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
/** Factory for {@link HlsPlaylist} parsers. */
@UnstableApi
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
* HlsMultivariantPlaylist}. Returned {@link HlsMediaPlaylist} instances may inherit attributes
* from {@code multivariantPlaylist}.
*
* @param multivariantPlaylist The multivariant playlist that referenced any parsed media
* playlists.
* @param previousMediaPlaylist The previous media playlist or null if there is no previous media
* playlist.
* @return A parser for HLS playlists.
*/
ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(
HlsMultivariantPlaylist multivariantPlaylist,
@Nullable HlsMediaPlaylist previousMediaPlaylist);
}
@@ -0,0 +1,305 @@
/*
* 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 androidx.media3.exoplayer.hls.playlist;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.SystemClock;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.hls.HlsDataSourceFactory;
import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import androidx.media3.exoplayer.upstream.CmcdConfiguration;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.contentsteering.ContentSteeringTracker;
import androidx.media3.exoplayer.util.ReleasableExecutor;
import com.google.common.base.Supplier;
import java.io.IOException;
import java.util.List;
/**
* 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 multivariant 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 exclude them to ensure a
* primary playlist is always available.
*/
@UnstableApi
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.
* @param cmcdConfiguration The {@link CmcdConfiguration} to use for playlist loading.
* @param downloadExecutorSupplier A supplier for a {@link ReleasableExecutor} that is used for
* loading the playlist.
*/
HlsPlaylistTracker createTracker(
HlsDataSourceFactory dataSourceFactory,
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
HlsPlaylistParserFactory playlistParserFactory,
@Nullable CmcdConfiguration cmcdConfiguration,
@Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier);
}
/** 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 loadErrorInfo The load error info.
* @param forceRetry Whether retry should be forced without considering exclusion.
* @return Whether the playlist will be excluded from future loads.
*/
boolean onPlaylistError(
Uri url, LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo, boolean forceRetry);
}
/** 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
* multivariant playlist.
* @param eventDispatcher A dispatcher to notify of events.
* @param primaryPlaylistListener A callback for the primary playlist change events.
* @param bandwidthMeter A {@link BandwidthMeter}.
*/
void start(
Uri initialPlaylistUri,
EventDispatcher eventDispatcher,
PrimaryPlaylistListener primaryPlaylistListener,
BandwidthMeter bandwidthMeter);
/**
* 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 multivariant playlist.
*
* <p>If the uri passed to {@link #start} points to a media playlist, an {@link
* HlsMultivariantPlaylist} with a single variant for said media playlist is returned.
*
* @return The multivariant playlist. Null if the initial playlist has yet to be loaded.
*/
@Nullable
HlsMultivariantPlaylist getMultivariantPlaylist();
/**
* Returns the {@linkplain ContentSteeringTracker content steering tracker}.
*
* @return The content steering tracker. Null if the initial playlist has yet to be loaded, or no
* information for content steering is declared in the initial playlist.
*/
@Nullable
ContentSteeringTracker getContentSteeringTracker();
/**
* Returns the {@link HlsRedundantGroup} list corresponding to the {@code type}.
*
* @param type The type of the requested {@link HlsRedundantGroup}.
* @return The list of requested {@link HlsRedundantGroup}. Null if the initial playlist has yet
* to be loaded.
*/
@Nullable
List<HlsRedundantGroup> getRedundantGroups(@HlsRedundantGroup.Type int type);
/**
* 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 multivariant 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 underlying error.
*/
void maybeThrowPlaylistRefreshError(Uri url) throws IOException;
/**
* Excludes the given media playlist for the given duration, in milliseconds.
*
* @param playlistUrl The URL of the media playlist.
* @param exclusionDurationMs The duration for which to exclude the playlist.
* @return Whether exclusion was successful.
*/
boolean excludeMediaPlaylist(Uri playlistUrl, long exclusionDurationMs);
/**
* Requests a playlist refresh and removes it from the exclusion list.
*
* <p>The playlist tracker may choose to 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();
/**
* Deactivate the playlist for playback.
*
* <p>The default implementation is a no-op.
*
* @param url The {@link Uri} of the playlist to deactivate for playback.
*/
default void deactivatePlaylistForPlayback(Uri url) {}
/**
* Returns whether the {@code playlistUrl} is excluded at the given {@code nowMs} time.
*
* @param playlistUrl The URL of the media playlist.
* @param nowMs The current value of {@link SystemClock#elapsedRealtime()}.
*/
boolean isExcluded(Uri playlistUrl, long nowMs);
/**
* Returns whether all playlist URLs in the {@link HlsRedundantGroup} are {@linkplain
* #isExcluded(Uri, long) excluded} at the given {@code nowMs} time.
*
* @param redundantGroup The {@link HlsRedundantGroup}.
* @param nowMs The current value of {@link SystemClock#elapsedRealtime()}.
*/
boolean isExcluded(HlsRedundantGroup redundantGroup, long nowMs);
/**
* Returns the {@link HlsRedundantGroup} where the {@code playlistUrl} belongs.
*
* @param playlistUrl The URL of the media playlist.
*/
@Nullable
HlsRedundantGroup getRedundantGroup(Uri playlistUrl);
}
@@ -0,0 +1,588 @@
/*
* Copyright 2026 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
*
* https://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 androidx.media3.exoplayer.hls.playlist;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static java.lang.annotation.ElementType.TYPE_USE;
import android.net.Uri;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Rendition;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Variant;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* Represents a group of {@linkplain Variant variants} or {@linkplain Rendition renditions} that are
* identical streams but from different locations (different playlist urls), and indicated by a
* shared {@link GroupKey}.
*/
@UnstableApi
public final class HlsRedundantGroup {
/**
* Represents the type of a {@link HlsRedundantGroup}. One of {@link #VARIANT}, {@link
* #VIDEO_RENDITION}, {@link #AUDIO_RENDITION} and {@link #SUBTITLE_RENDITION}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef(value = {VARIANT, VIDEO_RENDITION, AUDIO_RENDITION, SUBTITLE_RENDITION})
public @interface Type {}
/** The {@link HlsRedundantGroup} groups {@linkplain Variant variants}. */
public static final @Type int VARIANT = 0;
/** The {@link HlsRedundantGroup} groups {@linkplain Rendition video renditions}. */
public static final @Type int VIDEO_RENDITION = 1;
/** The {@link HlsRedundantGroup} groups {@linkplain Rendition audio renditions}. */
public static final @Type int AUDIO_RENDITION = 2;
/** The {@link HlsRedundantGroup} groups {@linkplain Rendition subtitle renditions}. */
public static final @Type int SUBTITLE_RENDITION = 3;
/**
* A factory for creating lists of {@linkplain HlsRedundantGroup redundant groups} from a {@link
* HlsMultivariantPlaylist}.
*/
public static class Factory {
private final HlsMultivariantPlaylist multivariantPlaylist;
private final boolean contentSteeringEnabled;
private final Map<String, String> videoGroupIdToPathwayId;
private final Map<String, String> audioGroupIdToPathwayId;
private final Map<String, String> subtitleGroupIdToPathwayId;
private @MonotonicNonNull ImmutableList<HlsRedundantGroup> variantRedundantGroupList;
private @MonotonicNonNull ImmutableList<HlsRedundantGroup> videoRenditionRedundantGroupList;
private @MonotonicNonNull ImmutableList<HlsRedundantGroup> audioRenditionRedundantGroupList;
private @MonotonicNonNull ImmutableList<HlsRedundantGroup> subtitleRenditionRedundantGroupList;
/**
* Creates a new factory.
*
* @param multivariantPlaylist The {@link HlsMultivariantPlaylist} for which to create
* {@linkplain HlsRedundantGroup redundant groups}.
*/
public Factory(HlsMultivariantPlaylist multivariantPlaylist) {
this.multivariantPlaylist = multivariantPlaylist;
this.contentSteeringEnabled = multivariantPlaylist.contentSteeringInfo != null;
videoGroupIdToPathwayId = new HashMap<>();
audioGroupIdToPathwayId = new HashMap<>();
subtitleGroupIdToPathwayId = new HashMap<>();
}
/**
* Creates a list of {@linkplain HlsRedundantGroup redundant groups} for {@linkplain Variant
* variants}.
*
* <p>If a {@link Variant} has a non-null {@linkplain Variant#pathwayId pathway ID}, then its
* {@link Variant#url} will be associated with that pathway ID when added to the {@link
* HlsRedundantGroup}. Otherwise, a generated pathway ID will be used. The first is '.', and the
* subsequent ones for the same group are '..', '...', according to the order they are listed in
* the {@link HlsMultivariantPlaylist}.
*
* @return A list of {@linkplain HlsRedundantGroup redundant groups}.
* @throws ParserException If two variants grouped into one {@link HlsRedundantGroup} have the
* same {@link Variant#pathwayId} but different {@linkplain Variant#url urls}.
* @throws ParserException If content steering enabled and a {@linkplain Variant#videoGroupId
* video group ID}, {@link Variant#audioGroupId audio group ID} or {@link
* Variant#subtitleGroupId subtitle group ID} is associated with more than one pathway.
* @throws ParserException If content steering is enabled and the set of available pathway IDs
* is inconsistent among variant redundant groups.
*/
public ImmutableList<HlsRedundantGroup> createVariantRedundantGroupList()
throws ParserException {
ensureVariantRedundantGroupListCreated();
return variantRedundantGroupList;
}
/**
* Creates a list of {@linkplain HlsRedundantGroup redundant groups} for {@linkplain Rendition
* renditions} with given {@code type}.
*
* <p>If content steering is enabled, then a pathway Id will be determined by the {@linkplain
* Rendition#groupId group ID} and the {@linkplain Variant#pathwayId pathway ID} of the
* {@linkplain Variant} that links to the rendition. Otherwise, a generated pathway ID will be
* used. The first is '.', and the subsequent ones for the same group are '..', '...', according
* to the order they are listed in the {@link HlsMultivariantPlaylist}.
*
* @param type The type of {@linkplain Rendition renditions} for which to create {@linkplain
* HlsRedundantGroup redundant groups}.
* @return A list of {@linkplain HlsRedundantGroup redundant groups}.
* @throws ParserException If two renditions grouped into one {@link HlsRedundantGroup} have the
* same pathway ID but different {@linkplain Rendition#url urls}.
* @throws ParserException If content steering enabled and a {@linkplain Rendition#groupId group
* ID} is associated with more than one pathway.
* @throws ParserException If content steering is enabled and the set of available pathway IDs
* of a rendition redundant group is inconsistent with the variant redundant groups.
* @throws IllegalArgumentException If the {@code type} is not one of {@link #VIDEO_RENDITION},
* {@link #AUDIO_RENDITION} or {@link #SUBTITLE_RENDITION}.
*/
public ImmutableList<HlsRedundantGroup> createRenditionRedundantGroupList(@Type int type)
throws ParserException {
ensureVariantRedundantGroupListCreated();
switch (type) {
case VIDEO_RENDITION:
if (videoRenditionRedundantGroupList == null) {
videoRenditionRedundantGroupList =
createAndValidateRenditionRedundantGroupList(
multivariantPlaylist.videos, videoGroupIdToPathwayId);
}
return videoRenditionRedundantGroupList;
case AUDIO_RENDITION:
if (audioRenditionRedundantGroupList == null) {
audioRenditionRedundantGroupList =
createAndValidateRenditionRedundantGroupList(
multivariantPlaylist.audios, audioGroupIdToPathwayId);
}
return audioRenditionRedundantGroupList;
case SUBTITLE_RENDITION:
if (subtitleRenditionRedundantGroupList == null) {
subtitleRenditionRedundantGroupList =
createAndValidateRenditionRedundantGroupList(
multivariantPlaylist.subtitles, subtitleGroupIdToPathwayId);
}
return subtitleRenditionRedundantGroupList;
default:
throw new IllegalArgumentException(
"Invalid type for creating rendition redundant group list");
}
}
@EnsuresNonNull("variantRedundantGroupList")
private void ensureVariantRedundantGroupListCreated() throws ParserException {
if (variantRedundantGroupList == null) {
ArrayList<HlsRedundantGroup> redundantGroupList = new ArrayList<>();
HashMap<GroupKey, Integer> redundantGroupIndices = new HashMap<>();
HashMap<GroupKey, Integer> generatedPathwayIdCounts = new HashMap<>();
for (int i = 0; i < multivariantPlaylist.variants.size(); i++) {
Variant variant = multivariantPlaylist.variants.get(i);
GroupKey groupKey = new GroupKey(variant.format, variant.stableVariantId);
String pathwayId =
propagateRedundantGroupList(
variant.url,
variant.pathwayId,
/* indexInMultivariantPlaylist= */ i,
redundantGroupList,
groupKey,
redundantGroupIndices,
generatedPathwayIdCounts);
if (contentSteeringEnabled) {
if (variant.videoGroupId != null) {
updateGroupIdToPathwayIdMapping(
videoGroupIdToPathwayId, variant.videoGroupId, pathwayId);
}
if (variant.audioGroupId != null) {
updateGroupIdToPathwayIdMapping(
audioGroupIdToPathwayId, variant.audioGroupId, pathwayId);
}
if (variant.subtitleGroupId != null) {
updateGroupIdToPathwayIdMapping(
subtitleGroupIdToPathwayId, variant.subtitleGroupId, pathwayId);
}
}
}
variantRedundantGroupList = ImmutableList.copyOf(redundantGroupList);
validateVariantRedundantGroupList();
}
}
@RequiresNonNull("variantRedundantGroupList")
private ImmutableList<HlsRedundantGroup> createAndValidateRenditionRedundantGroupList(
List<Rendition> renditions, Map<String, String> groupIdToPathwayId) throws ParserException {
ArrayList<HlsRedundantGroup> redundantGroupList = new ArrayList<>();
HashMap<GroupKey, Integer> redundantGroupIndices = new HashMap<>();
HashMap<GroupKey, Integer> generatedPathwayIdCounts = new HashMap<>();
for (int i = 0; i < renditions.size(); i++) {
Rendition rendition = renditions.get(i);
if (rendition.url == null) {
continue;
}
GroupKey groupKey =
new GroupKey(rendition.format, rendition.stableRenditionId, rendition.name);
@Nullable String knownPathwayId = null;
if (contentSteeringEnabled) {
knownPathwayId = groupIdToPathwayId.get(rendition.groupId);
if (knownPathwayId == null) {
// When Content Steering is enabled, we skip the rendition if we don't know its pathway
// ID. This also implies that there is no variant referring to this rendition with the
// rendition.groupId.
continue;
}
}
propagateRedundantGroupList(
rendition.url,
knownPathwayId,
/* indexInMultivariantPlaylist= */ i,
redundantGroupList,
groupKey,
redundantGroupIndices,
generatedPathwayIdCounts);
}
validateRenditionRedundantGroupList(redundantGroupList);
return ImmutableList.copyOf(redundantGroupList);
}
private static void updateGroupIdToPathwayIdMapping(
Map<String, String> groupIdToPathwayId, String groupId, String pathwayId)
throws ParserException {
if (groupIdToPathwayId.containsKey(groupId)
&& !pathwayId.equals(groupIdToPathwayId.get(groupId))) {
throw ParserException.createForMalformedManifest(
String.format(
"The group ID %s is associated with more than one pathway from" + " variants",
groupId),
/* cause= */ null);
}
groupIdToPathwayId.put(groupId, pathwayId);
}
/**
* Propagates a list of {@link HlsRedundantGroup} with a {@link Uri playlist url}.
*
* <p>This method either creates a new {@link HlsRedundantGroup} for the provided {@link Uri}
* and adds it to the passed {@code redundantGroupList}, or adds the provided {@link Uri} into
* an existing {@link HlsRedundantGroup} in the {@code redundantGroupList}.
*
* @param url The {@link Uri} to be added either into a new {@link HlsRedundantGroup} or an
* existing {@link HlsRedundantGroup}.
* @param knownPathwayId The known pathway ID of the {@code url}, which will be added with the
* {@code url} to an {@link HlsRedundantGroup}. If {@code null} is passed, a generated
* pathway ID will be used. The first is '.', and the subsequent ones for the same group are
* '..', '...', according to the order of the corresponding {@link Variant} or {@link
* Rendition} in the {@link HlsMultivariantPlaylist}.
* @param indexInMultivariantPlaylist The index of the {@code playlistUrl} in either {@link
* HlsMultivariantPlaylist#variants}, {@link HlsMultivariantPlaylist#videos}, {@link
* HlsMultivariantPlaylist#audios} or {@link HlsMultivariantPlaylist#subtitles}. May be
* {@link C#INDEX_UNSET} if the {@code playlistUrl} is not declared in the multivariant
* playlist.
* @param redundantGroupList The list of {@link HlsRedundantGroup} to be propagated.
* @param groupKey The {@link GroupKey} to look up the existing {@link HlsRedundantGroup}.
* @param redundantGroupIndices A map to look up an existing {@link HlsRedundantGroup} for a
* {@link GroupKey}. The keys are the {@link GroupKey group keys} of the {@link
* HlsRedundantGroup redundant groups}, and the values are the indices of the corresponding
* {@link HlsRedundantGroup redundant groups} in the {@code redundantGroupList}.
* @param generatedPathwayIdCounts A map to look up the count of the generated pathway ID for an
* {@link HlsRedundantGroup} of a {@link GroupKey}. The keys are the {@link GroupKey group
* keys} of the {@link HlsRedundantGroup redundant groups}, and the values are the counts of
* the generated pathway IDs of the {@link HlsRedundantGroup redundant groups}.
* @return The pathway ID of the given {@code url}, which is either the {@code knownPathwayId}
* if provided, or a newly generated pathway ID.
* @throws ParserException If two different urls grouped into one {@link HlsRedundantGroup} have
* the same pathway ID to associate with.
*/
@CanIgnoreReturnValue
private String propagateRedundantGroupList(
Uri url,
@Nullable String knownPathwayId,
int indexInMultivariantPlaylist,
List<HlsRedundantGroup> redundantGroupList,
GroupKey groupKey,
Map<GroupKey, Integer> redundantGroupIndices,
Map<GroupKey, Integer> generatedPathwayIdCounts)
throws ParserException {
@Nullable String pathwayId = knownPathwayId;
@Nullable Integer redundantGroupIndex = redundantGroupIndices.get(groupKey);
if (redundantGroupIndex == null) {
if (pathwayId == null) {
// The default pathwayId is ".".
pathwayId = ".";
generatedPathwayIdCounts.put(groupKey, 1);
} else {
generatedPathwayIdCounts.put(groupKey, 0);
}
HlsRedundantGroup newRedundantGroup =
new HlsRedundantGroup(groupKey, pathwayId, url, indexInMultivariantPlaylist);
redundantGroupIndices.put(groupKey, redundantGroupList.size());
redundantGroupList.add(newRedundantGroup);
} else {
if (pathwayId == null) {
// In the absence of a pathwayId, a generated pathway ID is used. The first is '.', and
// the subsequent ones for the same group are '..', '...' and so on.
int generatedPathwayIdCount = checkNotNull(generatedPathwayIdCounts.get(groupKey));
if (contentSteeringEnabled && generatedPathwayIdCount >= 1) {
throw ParserException.createForMalformedManifest(
"At most one playlist URL within an HlsRedundantGroup can have an undefined pathway"
+ " when Content Steering is enabled",
/* cause= */ null);
}
pathwayId = Strings.repeat(".", ++generatedPathwayIdCount);
generatedPathwayIdCounts.put(groupKey, generatedPathwayIdCount);
}
HlsRedundantGroup redundantGroup = redundantGroupList.get(redundantGroupIndex);
@Nullable Uri existingUrl = redundantGroup.getPlaylistUrl(pathwayId);
if (existingUrl != null && !url.equals(existingUrl)) {
throw ParserException.createForMalformedManifest(
String.format(
"Different playlist URLs are found for pathway ID %s within the"
+ " HlsRedundantGroup",
pathwayId),
/* cause= */ null);
}
redundantGroup.put(pathwayId, url, indexInMultivariantPlaylist);
}
return pathwayId;
}
@RequiresNonNull("variantRedundantGroupList")
private void validateVariantRedundantGroupList() throws ParserException {
if (!contentSteeringEnabled) {
return;
}
Set<String> pathwayIdSet = checkNotNull(variantRedundantGroupList.get(0)).getAllPathwayIds();
for (int i = 1; i < variantRedundantGroupList.size(); i++) {
HlsRedundantGroup redundantGroup = variantRedundantGroupList.get(i);
if (!redundantGroup.getAllPathwayIds().equals(pathwayIdSet)) {
throw ParserException.createForMalformedManifest(
"The set of available pathway IDs is inconsistent among variant redundant groups",
/* cause= */ null);
}
}
}
@RequiresNonNull("variantRedundantGroupList")
private void validateRenditionRedundantGroupList(
List<HlsRedundantGroup> renditionRedundantGroupList) throws ParserException {
if (!contentSteeringEnabled) {
return;
}
Set<String> pathwayIdSet = checkNotNull(variantRedundantGroupList.get(0)).getAllPathwayIds();
for (int i = 0; i < renditionRedundantGroupList.size(); i++) {
HlsRedundantGroup redundantGroup = renditionRedundantGroupList.get(i);
if (!redundantGroup.getAllPathwayIds().equals(pathwayIdSet)) {
throw ParserException.createForMalformedManifest(
"The set of available pathway IDs of a rendition redundant group is inconsistent with"
+ " variant redundant groups",
/* cause= */ null);
}
}
}
}
/**
* Defines a key that groups {@linkplain Variant variants} or {@linkplain Rendition renditions}
* into one {@link HlsRedundantGroup}.
*/
public static class GroupKey {
/**
* The {@link Format} shared by the {@linkplain Variant grouped variants} or the {@linkplain
* Rendition grouped renditions}.
*/
public final Format format;
/**
* The stable identifier shared by variants or renditions, or {@code null}.
*
* <p>If the {@link GroupKey} groups {@linkplain Variant variants}, then this field is the
* {@link Variant#stableVariantId} of the variants; If the {@link GroupKey} groups {@linkplain
* Rendition renditions}, then this field is the {@link Rendition#stableRenditionId} of the
* renditions.
*/
@Nullable public final String stableId;
/**
* The {@linkplain Rendition#name name} of the {@linkplain Rendition grouped renditions}. This
* can be {@code null} if unknown or the {@link GroupKey} groups {@linkplain Variant variants}.
*/
@Nullable public final String name;
/**
* Creates a {@link GroupKey}.
*
* @param format See {@link #format}.
* @param stableId See {@link #stableId}.
*/
public GroupKey(Format format, @Nullable String stableId) {
this(format, stableId, /* name= */ null);
}
/**
* Creates a {@link GroupKey}.
*
* @param format See {@link #format}.
* @param stableId See {@link #stableId}.
* @param name See {@link #name}.
*/
public GroupKey(Format format, @Nullable String stableId, @Nullable String name) {
// Normalize the format to ensure only fields affecting identity are part of the key.
this.format = format.buildUpon().setId(null).setMetadata(null).build();
this.stableId = stableId;
this.name = name;
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof GroupKey)) {
return false;
}
GroupKey groupKey = (GroupKey) other;
return Objects.equals(format, groupKey.format)
&& Objects.equals(stableId, groupKey.stableId)
&& Objects.equals(name, groupKey.name);
}
@Override
public int hashCode() {
return Objects.hash(format, stableId, name);
}
}
/**
* The {@link GroupKey} that the {@linkplain Variant grouped variants} or {@linkplain Rendition
* grouped renditions} share.
*/
public final GroupKey groupKey;
private final HashMap<String, Uri> pathwayIdToPlaylistUrl;
private final List<Integer> indicesInMultivariantPlaylist;
private String currentPathwayId;
/**
* Creates a {@link HlsRedundantGroup}.
*
* @param groupKey See {@link #groupKey}.
* @param pathwayId The ID of the default current pathway.
* @param playlistUrl The playlist url that is associated with the default current pathway.
*/
public HlsRedundantGroup(GroupKey groupKey, String pathwayId, Uri playlistUrl) {
this(groupKey, pathwayId, playlistUrl, C.INDEX_UNSET);
}
/**
* Creates a {@link HlsRedundantGroup}.
*
* @param groupKey See {@link #groupKey}.
* @param pathwayId The ID of the default current pathway.
* @param playlistUrl The playlist url that is associated with the default current pathway.
* @param indexInMultivariantPlaylist The index of the {@code playlistUrl} in either {@link
* HlsMultivariantPlaylist#variants}, {@link HlsMultivariantPlaylist#videos}, {@link
* HlsMultivariantPlaylist#audios} or {@link HlsMultivariantPlaylist#subtitles}. May be {@link
* C#INDEX_UNSET} if the {@code playlistUrl} is not declared in the multivariant playlist.
*/
public HlsRedundantGroup(
GroupKey groupKey, String pathwayId, Uri playlistUrl, int indexInMultivariantPlaylist) {
this.groupKey = groupKey;
this.pathwayIdToPlaylistUrl = new HashMap<>();
this.pathwayIdToPlaylistUrl.put(pathwayId, playlistUrl);
this.currentPathwayId = pathwayId;
this.indicesInMultivariantPlaylist = new ArrayList<>();
if (indexInMultivariantPlaylist != C.INDEX_UNSET) {
indicesInMultivariantPlaylist.add(indexInMultivariantPlaylist);
}
}
/**
* Puts a {@code playlistUrl} and its associated {@code pathwayId} to the {@link
* HlsRedundantGroup}.
*/
public void put(String pathwayId, Uri playlistUrl) {
put(pathwayId, playlistUrl, C.INDEX_UNSET);
}
/**
* Puts a {@code playlistUrl} and its associated {@code pathwayId} to the {@link
* HlsRedundantGroup}.
*
* @param pathwayId The ID of the pathway to add.
* @param playlistUrl The playlist url to add.
* @param indexInMultivariantPlaylist The index of the {@code playlistUrl} in either {@link
* HlsMultivariantPlaylist#variants}, {@link HlsMultivariantPlaylist#videos}, {@link
* HlsMultivariantPlaylist#audios} or {@link HlsMultivariantPlaylist#subtitles}. May be {@link
* C#INDEX_UNSET} if the {@code playlistUrl} is not declared in the multivariant playlist.
*/
public void put(String pathwayId, Uri playlistUrl, int indexInMultivariantPlaylist) {
pathwayIdToPlaylistUrl.put(pathwayId, playlistUrl);
if (indexInMultivariantPlaylist != C.INDEX_UNSET) {
indicesInMultivariantPlaylist.add(indexInMultivariantPlaylist);
}
}
/** Returns the size of the {@link HlsRedundantGroup}. */
public int size() {
return pathwayIdToPlaylistUrl.size();
}
/** Sets the {@code pathwayId} that is currently chosen for playback. */
public void setCurrentPathwayId(String pathwayId) {
checkState(pathwayIdToPlaylistUrl.containsKey(pathwayId));
currentPathwayId = pathwayId;
}
/** Returns the pathway ID that is currently chosen for playback. */
public String getCurrentPathwayId() {
return currentPathwayId;
}
/** Returns all pathway IDs that belong to this {@link HlsRedundantGroup}. */
public ImmutableSet<String> getAllPathwayIds() {
return ImmutableSet.copyOf(pathwayIdToPlaylistUrl.keySet());
}
/** Returns the playlist url that is associated with the currently chosen pathway ID. */
public Uri getCurrentPlaylistUrl() {
return checkNotNull(pathwayIdToPlaylistUrl.get(currentPathwayId));
}
/**
* Returns the playlist url associated with the given {@code pathwayId}, or {@code null} if the
* given {@code pathwayId} doesn't exist in this {@link HlsRedundantGroup}.
*/
@Nullable
public Uri getPlaylistUrl(String pathwayId) {
return pathwayIdToPlaylistUrl.get(pathwayId);
}
/** Returns all playlist urls that belong to this {@link HlsRedundantGroup}. */
public ImmutableSet<Uri> getAllPlaylistUrls() {
return ImmutableSet.copyOf(pathwayIdToPlaylistUrl.values());
}
/**
* Returns the indices of the {@linkplain Variant variants} or {@linkplain Rendition renditions}
* belonging to this {@link HlsRedundantGroup} in either {@link HlsMultivariantPlaylist#variants},
* {@link HlsMultivariantPlaylist#videos}, {@link HlsMultivariantPlaylist#audios} or {@link
* HlsMultivariantPlaylist#subtitles}.
*/
public ImmutableList<Integer> getIndicesInMultivariantPlaylist() {
return ImmutableList.copyOf(indicesInMultivariantPlaylist);
}
}
@@ -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 androidx.media3.exoplayer.hls.playlist;
import androidx.media3.common.util.NonNullApi;