/*
* 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.common.util.Assertions.checkNotNull;
import android.annotation.SuppressLint;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media3.common.FileTypes;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
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.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.primitives.Ints;
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 final boolean exposeCea608WhenMissingDeclarations;
/**
* Equivalent to {@link #DefaultHlsExtractorFactory(int, boolean) new
* DefaultHlsExtractorFactory(payloadReaderFactoryFlags = 0, exposeCea608WhenMissingDeclarations =
* true)}
*/
public DefaultHlsExtractorFactory() {
this(/* payloadReaderFactoryFlags= */ 0, /* exposeCea608WhenMissingDeclarations */ true);
}
/**
* Creates a factory for HLS segment extractors.
*
* @param payloadReaderFactoryFlags Flags to add when constructing any {@link
* DefaultTsPayloadReaderFactory} instances. Other flags may be added on top of {@code
* payloadReaderFactoryFlags} when creating {@link DefaultTsPayloadReaderFactory}.
* @param exposeCea608WhenMissingDeclarations Whether created {@link TsExtractor} instances should
* expose a CEA-608 track should the 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;
}
@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);
}
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);
}
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) {
switch (fileType) {
case FileTypes.WEBVTT:
return new WebvttExtractor(format.language, timestampAdjuster);
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(timestampAdjuster, format, muxedCaptionFormats);
case FileTypes.TS:
return createTsExtractor(
payloadReaderFactoryFlags,
exposeCea608WhenMissingDeclarations,
format,
muxedCaptionFormats,
timestampAdjuster);
default:
return null;
}
}
private static TsExtractor createTsExtractor(
@DefaultTsPayloadReaderFactory.Flags int userProvidedPayloadReaderFactoryFlags,
boolean exposeCea608WhenMissingDeclarations,
Format format,
@Nullable List<Format> muxedCaptionFormats,
TimestampAdjuster timestampAdjuster) {
@DefaultTsPayloadReaderFactory.Flags
int payloadReaderFactoryFlags =
DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM
| userProvidedPayloadReaderFactoryFlags;
if (muxedCaptionFormats != null) {
// The playlist declares closed caption renditions, we should ignore descriptors.
payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_OVERRIDE_CAPTION_DESCRIPTORS;
} else if (exposeCea608WhenMissingDeclarations) {
// The playlist does not provide any closed caption information. We preemptively declare a
// closed caption track on channel 0.
muxedCaptionFormats =
Collections.singletonList(
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;
}
}
return new TsExtractor(
TsExtractor.MODE_HLS,
timestampAdjuster,
new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats));
}
private static FragmentedMp4Extractor createFragmentedMp4Extractor(
TimestampAdjuster timestampAdjuster,
Format format,
@Nullable List<Format> muxedCaptionFormats) {
// Only enable the EMSG TrackOutput if this is the 'variant' track (i.e. the main one) to avoid
// creating a separate EMSG track for every audio track in a video stream.
return new FragmentedMp4Extractor(
/* flags= */ isFmp4Variant(format) ? FragmentedMp4Extractor.FLAG_ENABLE_EMSG_TRACK : 0,
timestampAdjuster,
/* sideloadedTrack= */ null,
muxedCaptionFormats != null ? muxedCaptionFormats : Collections.emptyList());
}
/** Returns true if this {@code format} represents a 'variant' track (i.e. the main one). */
private static boolean isFmp4Variant(Format format) {
Metadata metadata = format.metadata;
if (metadata == null) {
return false;
}
for (int i = 0; i < metadata.length(); i++) {
Metadata.Entry entry = metadata.get(i);
if (entry instanceof HlsTrackMetadataEntry) {
return !((HlsTrackMetadataEntry) entry).variantInfos.isEmpty();
}
}
return false;
}
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;
}
}