/*
* 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.exoplayer.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_PUBLISHED;
import static androidx.media3.exoplayer.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_REMOVED;
import static androidx.media3.exoplayer.trackselection.TrackSelectionUtil.createFallbackOptions;
import static java.lang.Math.max;
import static java.lang.Math.min;
import android.net.Uri;
import android.os.Handler;
import android.util.SparseIntArray;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.DataReader;
import androidx.media3.common.DrmInitData;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.HttpDataSource;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.exoplayer.SeekParameters;
import androidx.media3.exoplayer.drm.DrmSession;
import androidx.media3.exoplayer.drm.DrmSessionEventListener;
import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaSourceEventListener;
import androidx.media3.exoplayer.source.SampleQueue;
import androidx.media3.exoplayer.source.SampleQueue.UpstreamFormatChangedListener;
import androidx.media3.exoplayer.source.SampleStream;
import androidx.media3.exoplayer.source.SampleStream.ReadFlags;
import androidx.media3.exoplayer.source.SequenceableLoader;
import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.source.chunk.Chunk;
import androidx.media3.exoplayer.source.chunk.MediaChunkIterator;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo;
import androidx.media3.exoplayer.upstream.Loader;
import androidx.media3.exoplayer.upstream.Loader.LoadErrorAction;
import androidx.media3.extractor.DummyTrackOutput;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.TrackOutput;
import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.emsg.EventMessageDecoder;
import androidx.media3.extractor.metadata.id3.PrivFrame;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Ints;
import java.io.EOFException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.checkerframework.checker.nullness.compatqual.NullableType;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* Loads {@link HlsMediaChunk}s obtained from a {@link HlsChunkSource}, and provides {@link
* SampleStream}s from which the loaded media can be consumed.
*/
/* package */ final class HlsSampleStreamWrapper
implements Loader.Callback<Chunk>,
Loader.ReleaseCallback,
SequenceableLoader,
ExtractorOutput,
UpstreamFormatChangedListener {
/** A callback to be notified of events. */
public interface Callback extends SequenceableLoader.Callback<HlsSampleStreamWrapper> {
/**
* Called when the wrapper has been prepared.
*
* <p>Note: This method will be called on a later handler loop than the one on which either
* {@link #prepareWithMultivariantPlaylistInfo} or {@link #continuePreparing} are invoked.
*/
void onPrepared();
/**
* Called to schedule a {@link #continueLoading(long)} call when the playlist referred by the
* given url changes.
*/
void onPlaylistRefreshRequired(Uri playlistUrl);
}
private static final String TAG = "HlsSampleStreamWrapper";
public static final int SAMPLE_QUEUE_INDEX_PENDING = -1;
public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL = -2;
public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL = -3;
private static final Set<Integer> MAPPABLE_TYPES =
Collections.unmodifiableSet(
new HashSet<>(
Arrays.asList(C.TRACK_TYPE_AUDIO, C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_METADATA)));
private final String uid;
private final @C.TrackType int trackType;
private final Callback callback;
private final HlsChunkSource chunkSource;
private final Allocator allocator;
@Nullable private final Format muxedAudioFormat;
private final DrmSessionManager drmSessionManager;
private final DrmSessionEventListener.EventDispatcher drmEventDispatcher;
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
private final Loader loader;
private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher;
private final @HlsMediaSource.MetadataType int metadataType;
private final HlsChunkSource.HlsChunkHolder nextChunkHolder;
private final ArrayList<HlsMediaChunk> mediaChunks;
private final List<HlsMediaChunk> readOnlyMediaChunks;
// Using runnables rather than in-line method references to avoid repeated allocations.
private final Runnable maybeFinishPrepareRunnable;
private final Runnable onTracksEndedRunnable;
private final Handler handler;
private final ArrayList<HlsSampleStream> hlsSampleStreams;
private final Map<String, DrmInitData> overridingDrmInitData;
@Nullable private Chunk loadingChunk;
private HlsSampleQueue[] sampleQueues;
private int[] sampleQueueTrackIds;
private Set<Integer> sampleQueueMappingDoneByType;
private SparseIntArray sampleQueueIndicesByType;
private @MonotonicNonNull TrackOutput emsgUnwrappingTrackOutput;
private int primarySampleQueueType;
private int primarySampleQueueIndex;
private boolean sampleQueuesBuilt;
private boolean prepared;
private int enabledTrackGroupCount;
private @MonotonicNonNull Format upstreamTrackFormat;
@Nullable private Format downstreamTrackFormat;
private boolean released;
// Tracks are complicated in HLS. See documentation of buildTracksFromSampleStreams for details.
// Indexed by track (as exposed by this source).
private @MonotonicNonNull TrackGroupArray trackGroups;
private @MonotonicNonNull Set<TrackGroup> optionalTrackGroups;
// Indexed by track group.
private int @MonotonicNonNull [] trackGroupToSampleQueueIndex;
private int primaryTrackGroupIndex;
private boolean haveAudioVideoSampleQueues;
private boolean[] sampleQueuesEnabledStates;
private boolean[] sampleQueueIsAudioVideoFlags;
private long lastSeekPositionUs;
private long pendingResetPositionUs;
private boolean pendingResetUpstreamFormats;
private boolean seenFirstTrackSelection;
private boolean loadingFinished;
// Accessed only by the loading thread.
private boolean tracksEnded;
private long sampleOffsetUs;
@Nullable private DrmInitData drmInitData;
@Nullable private HlsMediaChunk sourceChunk;
/**
* @param uid A identifier for this sample stream wrapper. Identifiers must be unique within the
* period.
* @param trackType The {@link C.TrackType track type}.
* @param callback A callback for the wrapper.
* @param chunkSource A {@link HlsChunkSource} from which chunks to load are obtained.
* @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type
* (i.e. {@link DrmInitData#schemeType}). If the stream has {@link DrmInitData} and uses a
* protection scheme type for which overriding {@link DrmInitData} is provided, then the
* stream's {@link DrmInitData} will be overridden.
* @param allocator An {@link Allocator} from which to obtain media buffer allocations.
* @param positionUs The position from which to start loading media.
* @param muxedAudioFormat Optional muxed audio {@link Format} as defined by the multivariant
* playlist.
* @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession
* DrmSessions} with.
* @param drmEventDispatcher A dispatcher to notify of {@link DrmSessionEventListener} events.
* @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}.
* @param mediaSourceEventDispatcher A dispatcher to notify of {@link MediaSourceEventListener}
* events.
*/
public HlsSampleStreamWrapper(
String uid,
@C.TrackType int trackType,
Callback callback,
HlsChunkSource chunkSource,
Map<String, DrmInitData> overridingDrmInitData,
Allocator allocator,
long positionUs,
@Nullable Format muxedAudioFormat,
DrmSessionManager drmSessionManager,
DrmSessionEventListener.EventDispatcher drmEventDispatcher,
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher,
@HlsMediaSource.MetadataType int metadataType) {
this.uid = uid;
this.trackType = trackType;
this.callback = callback;
this.chunkSource = chunkSource;
this.overridingDrmInitData = overridingDrmInitData;
this.allocator = allocator;
this.muxedAudioFormat = muxedAudioFormat;
this.drmSessionManager = drmSessionManager;
this.drmEventDispatcher = drmEventDispatcher;
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
this.mediaSourceEventDispatcher = mediaSourceEventDispatcher;
this.metadataType = metadataType;
loader = new Loader("Loader:HlsSampleStreamWrapper");
nextChunkHolder = new HlsChunkSource.HlsChunkHolder();
sampleQueueTrackIds = new int[0];
sampleQueueMappingDoneByType = new HashSet<>(MAPPABLE_TYPES.size());
sampleQueueIndicesByType = new SparseIntArray(MAPPABLE_TYPES.size());
sampleQueues = new HlsSampleQueue[0];
sampleQueueIsAudioVideoFlags = new boolean[0];
sampleQueuesEnabledStates = new boolean[0];
mediaChunks = new ArrayList<>();
readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks);
hlsSampleStreams = new ArrayList<>();
// Suppressions are needed because `this` is not initialized here.
@SuppressWarnings("nullness:methodref.receiver.bound")
Runnable maybeFinishPrepareRunnable = this::maybeFinishPrepare;
this.maybeFinishPrepareRunnable = maybeFinishPrepareRunnable;
@SuppressWarnings("nullness:methodref.receiver.bound")
Runnable onTracksEndedRunnable = this::onTracksEnded;
this.onTracksEndedRunnable = onTracksEndedRunnable;
handler = Util.createHandlerForCurrentLooper();
lastSeekPositionUs = positionUs;
pendingResetPositionUs = positionUs;
}
public void continuePreparing() {
if (!prepared) {
continueLoading(lastSeekPositionUs);
}
}
/**
* Prepares the sample stream wrapper with multivariant playlist information.
*
* @param trackGroups The {@link TrackGroup TrackGroups} to expose through {@link
* #getTrackGroups()}.
* @param primaryTrackGroupIndex The index of the adaptive track group.
* @param optionalTrackGroupsIndices The indices of any {@code trackGroups} that should not
* trigger a failure if not found in the media playlist's segments.
*/
public void prepareWithMultivariantPlaylistInfo(
TrackGroup[] trackGroups, int primaryTrackGroupIndex, int... optionalTrackGroupsIndices) {
this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups);
optionalTrackGroups = new HashSet<>();
for (int optionalTrackGroupIndex : optionalTrackGroupsIndices) {
optionalTrackGroups.add(this.trackGroups.get(optionalTrackGroupIndex));
}
this.primaryTrackGroupIndex = primaryTrackGroupIndex;
handler.post(callback::onPrepared);
setIsPrepared();
}
public void maybeThrowPrepareError() throws IOException {
maybeThrowError();
if (loadingFinished && !prepared) {
throw ParserException.createForMalformedContainer(
"Loading finished before preparation is complete.", /* cause= */ null);
}
}
public TrackGroupArray getTrackGroups() {
assertIsPrepared();
return trackGroups;
}
public int getPrimaryTrackGroupIndex() {
return primaryTrackGroupIndex;
}
public int bindSampleQueueToSampleStream(int trackGroupIndex) {
assertIsPrepared();
Assertions.checkNotNull(trackGroupToSampleQueueIndex);
int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex];
if (sampleQueueIndex == C.INDEX_UNSET) {
return optionalTrackGroups.contains(trackGroups.get(trackGroupIndex))
? SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL
: SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL;
}
if (sampleQueuesEnabledStates[sampleQueueIndex]) {
// This sample queue is already bound to a different sample stream.
return SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL;
}
sampleQueuesEnabledStates[sampleQueueIndex] = true;
return sampleQueueIndex;
}
public void unbindSampleQueue(int trackGroupIndex) {
assertIsPrepared();
Assertions.checkNotNull(trackGroupToSampleQueueIndex);
int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex];
Assertions.checkState(sampleQueuesEnabledStates[sampleQueueIndex]);
sampleQueuesEnabledStates[sampleQueueIndex] = false;
}
/**
* Called by the parent {@link HlsMediaPeriod} when a track selection occurs.
*
* @param selections The renderer track selections.
* @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained
* for each selection. A {@code true} value indicates that the selection is unchanged, and
* that the caller does not require that the sample stream be recreated.
* @param streams The existing sample streams, which will be updated to reflect the provided
* selections.
* @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that
* have been retained but with the requirement that the consuming renderer be reset.
* @param positionUs The current playback position in microseconds.
* @param forceReset If true then a reset is forced (i.e. a seek will be performed with in-buffer
* seeking disabled).
* @return Whether this wrapper requires the parent {@link HlsMediaPeriod} to perform a seek as
* part of the track selection.
*/
public boolean selectTracks(
@NullableType ExoTrackSelection[] selections,
boolean[] mayRetainStreamFlags,
@NullableType SampleStream[] streams,
boolean[] streamResetFlags,
long positionUs,
boolean forceReset) {
assertIsPrepared();
int oldEnabledTrackGroupCount = enabledTrackGroupCount;
// Deselect old tracks.
for (int i = 0; i < selections.length; i++) {
HlsSampleStream stream = (HlsSampleStream) streams[i];
if (stream != null && (selections[i] == null || !mayRetainStreamFlags[i])) {
enabledTrackGroupCount--;
stream.unbindSampleQueue();
streams[i] = null;
}
}
// We'll always need to seek if we're being forced to reset, or if this is a first selection to
// a position other than the one we started preparing with, or if we're making a selection
// having previously disabled all tracks.
boolean seekRequired =
forceReset
|| (seenFirstTrackSelection
? oldEnabledTrackGroupCount == 0
: positionUs != lastSeekPositionUs);
// Get the old (i.e. current before the loop below executes) primary track selection. The new
// primary selection will equal the old one unless it's changed in the loop.
ExoTrackSelection oldPrimaryTrackSelection = chunkSource.getTrackSelection();
ExoTrackSelection primaryTrackSelection = oldPrimaryTrackSelection;
// Select new tracks.
for (int i = 0; i < selections.length; i++) {
ExoTrackSelection selection = selections[i];
if (selection == null) {
continue;
}
int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup());
if (trackGroupIndex == primaryTrackGroupIndex) {
primaryTrackSelection = selection;
chunkSource.setTrackSelection(selection);
}
if (streams[i] == null) {
enabledTrackGroupCount++;
streams[i] = new HlsSampleStream(this, trackGroupIndex);
streamResetFlags[i] = true;
if (trackGroupToSampleQueueIndex != null) {
((HlsSampleStream) streams[i]).bindSampleQueue();
// If there's still a chance of avoiding a seek, try and seek within the sample queue.
if (!seekRequired) {
SampleQueue sampleQueue = sampleQueues[trackGroupToSampleQueueIndex[trackGroupIndex]];
// A seek can be avoided if we're able to seek to the current playback position in
// the sample queue, or if we haven't read anything from the queue since the previous
// seek (this case is common for sparse tracks such as metadata tracks). In all other
// cases a seek is required.
seekRequired =
!sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true)
&& sampleQueue.getReadIndex() != 0;
}
}
}
}
if (enabledTrackGroupCount == 0) {
chunkSource.reset();
downstreamTrackFormat = null;
pendingResetUpstreamFormats = true;
mediaChunks.clear();
if (loader.isLoading()) {
if (sampleQueuesBuilt) {
// Discard as much as we can synchronously.
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.discardToEnd();
}
}
loader.cancelLoading();
} else {
resetSampleQueues();
}
} else {
if (!mediaChunks.isEmpty()
&& !Util.areEqual(primaryTrackSelection, oldPrimaryTrackSelection)) {
// The primary track selection has changed and we have buffered media. The buffered media
// may need to be discarded.
boolean primarySampleQueueDirty = false;
if (!seenFirstTrackSelection) {
long bufferedDurationUs = positionUs < 0 ? -positionUs : 0;
HlsMediaChunk lastMediaChunk = getLastMediaChunk();
MediaChunkIterator[] mediaChunkIterators =
chunkSource.createMediaChunkIterators(lastMediaChunk, positionUs);
primaryTrackSelection.updateSelectedTrack(
positionUs,
bufferedDurationUs,
C.TIME_UNSET,
readOnlyMediaChunks,
mediaChunkIterators);
int chunkIndex = chunkSource.getTrackGroup().indexOf(lastMediaChunk.trackFormat);
if (primaryTrackSelection.getSelectedIndexInTrackGroup() != chunkIndex) {
// This is the first selection and the chunk loaded during preparation does not match
// the initially selected format.
primarySampleQueueDirty = true;
}
} else {
// The primary sample queue contains media buffered for the old primary track selection.
primarySampleQueueDirty = true;
}
if (primarySampleQueueDirty) {
forceReset = true;
seekRequired = true;
pendingResetUpstreamFormats = true;
}
}
if (seekRequired) {
seekToUs(positionUs, forceReset);
// We'll need to reset renderers consuming from all streams due to the seek.
for (int i = 0; i < streams.length; i++) {
if (streams[i] != null) {
streamResetFlags[i] = true;
}
}
}
}
updateSampleStreams(streams);
seenFirstTrackSelection = true;
return seekRequired;
}
public void discardBuffer(long positionUs, boolean toKeyframe) {
if (!sampleQueuesBuilt || isPendingReset()) {
return;
}
int sampleQueueCount = sampleQueues.length;
for (int i = 0; i < sampleQueueCount; i++) {
sampleQueues[i].discardTo(positionUs, toKeyframe, sampleQueuesEnabledStates[i]);
}
}
/**
* Attempts to seek to the specified position in microseconds.
*
* @param positionUs The seek position in microseconds.
* @param forceReset If true then a reset is forced (i.e. in-buffer seeking is disabled).
* @return Whether the wrapper was reset, meaning the wrapped sample queues were reset. If false,
* an in-buffer seek was performed.
*/
public boolean seekToUs(long positionUs, boolean forceReset) {
lastSeekPositionUs = positionUs;
if (isPendingReset()) {
// A reset is already pending. We only need to update its position.
pendingResetPositionUs = positionUs;
return true;
}
// If we're not forced to reset, try and seek within the buffer.
if (sampleQueuesBuilt && !forceReset && seekInsideBufferUs(positionUs)) {
return false;
}
// We can't seek inside the buffer, and so need to reset.
pendingResetPositionUs = positionUs;
loadingFinished = false;
mediaChunks.clear();
if (loader.isLoading()) {
if (sampleQueuesBuilt) {
// Discard as much as we can synchronously.
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.discardToEnd();
}
}
loader.cancelLoading();
} else {
loader.clearFatalError();
resetSampleQueues();
}
return true;
}
/** Called when the playlist is updated. */
public void onPlaylistUpdated() {
if (mediaChunks.isEmpty()) {
return;
}
HlsMediaChunk lastMediaChunk = Iterables.getLast(mediaChunks);
@HlsChunkSource.ChunkPublicationState
int chunkState = chunkSource.getChunkPublicationState(lastMediaChunk);
if (chunkState == CHUNK_PUBLICATION_STATE_PUBLISHED) {
lastMediaChunk.publish();
} else if (chunkState == CHUNK_PUBLICATION_STATE_REMOVED
&& !loadingFinished
&& loader.isLoading()) {
loader.cancelLoading();
}
}
public void release() {
if (prepared) {
// Discard as much as we can synchronously. We only do this if we're prepared, since otherwise
// sampleQueues may still be being modified by the loading thread.
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.preRelease();
}
}
loader.release(this);
handler.removeCallbacksAndMessages(null);
released = true;
hlsSampleStreams.clear();
}
@Override
public void onLoaderReleased() {
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.release();
}
}
public void setIsPrimaryTimestampSource(boolean isPrimaryTimestampSource) {
chunkSource.setIsPrimaryTimestampSource(isPrimaryTimestampSource);
}
/**
* Called if an error is encountered while loading a playlist.
*
* @param playlistUrl The {@link Uri} of the playlist whose load encountered an error.
* @param loadErrorInfo The load error info.
* @param forceRetry Whether retry should be forced without considering exclusion.
* @return True if excluding did not encounter errors. False otherwise.
*/
public boolean onPlaylistError(Uri playlistUrl, LoadErrorInfo loadErrorInfo, boolean forceRetry) {
if (!chunkSource.obtainsChunksForPlaylist(playlistUrl)) {
// Return early if the chunk source doesn't deliver chunks for the failing playlist.
return true;
}
long exclusionDurationMs = C.TIME_UNSET;
if (!forceRetry) {
@Nullable
LoadErrorHandlingPolicy.FallbackSelection fallbackSelection =
loadErrorHandlingPolicy.getFallbackSelectionFor(
createFallbackOptions(chunkSource.getTrackSelection()), loadErrorInfo);
if (fallbackSelection != null
&& fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
exclusionDurationMs = fallbackSelection.exclusionDurationMs;
}
}
// We must call ChunkSource.onPlaylistError in any case to give the chunk source the chance to
// mark the playlist as failing.
return chunkSource.onPlaylistError(playlistUrl, exclusionDurationMs)
&& exclusionDurationMs != C.TIME_UNSET;
}
/** Returns whether the primary sample stream is {@link C#TRACK_TYPE_VIDEO}. */
public boolean isVideoSampleStream() {
return primarySampleQueueType == C.TRACK_TYPE_VIDEO;
}
/**
* Adjusts a seek position given the specified {@link SeekParameters}.
*
* @param positionUs The seek position in microseconds.
* @param seekParameters Parameters that control how the seek is performed.
* @return The adjusted seek position, in microseconds.
*/
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
return chunkSource.getAdjustedSeekPositionUs(positionUs, seekParameters);
}
// SampleStream implementation.
public boolean isReady(int sampleQueueIndex) {
return !isPendingReset() && sampleQueues[sampleQueueIndex].isReady(loadingFinished);
}
public void maybeThrowError(int sampleQueueIndex) throws IOException {
maybeThrowError();
sampleQueues[sampleQueueIndex].maybeThrowError();
}
public void maybeThrowError() throws IOException {
loader.maybeThrowError();
chunkSource.maybeThrowError();
}
public int readData(
int sampleQueueIndex,
FormatHolder formatHolder,
DecoderInputBuffer buffer,
@ReadFlags int readFlags) {
if (isPendingReset()) {
return C.RESULT_NOTHING_READ;
}
// TODO: Split into discard (in discardBuffer) and format change (here and in skipData) steps.
if (!mediaChunks.isEmpty()) {
int discardToMediaChunkIndex = 0;
while (discardToMediaChunkIndex < mediaChunks.size() - 1
&& finishedReadingChunk(mediaChunks.get(discardToMediaChunkIndex))) {
discardToMediaChunkIndex++;
}
Util.removeRange(mediaChunks, 0, discardToMediaChunkIndex);
HlsMediaChunk currentChunk = mediaChunks.get(0);
Format trackFormat = currentChunk.trackFormat;
if (!trackFormat.equals(downstreamTrackFormat)) {
mediaSourceEventDispatcher.downstreamFormatChanged(
trackType,
trackFormat,
currentChunk.trackSelectionReason,
currentChunk.trackSelectionData,
currentChunk.startTimeUs);
}
downstreamTrackFormat = trackFormat;
}
if (!mediaChunks.isEmpty() && !mediaChunks.get(0).isPublished()) {
// Don't read into preload chunks until we can be sure they are permanently published.
return C.RESULT_NOTHING_READ;
}
int result =
sampleQueues[sampleQueueIndex].read(formatHolder, buffer, readFlags, loadingFinished);
if (result == C.RESULT_FORMAT_READ) {
Format format = Assertions.checkNotNull(formatHolder.format);
if (sampleQueueIndex == primarySampleQueueIndex) {
// Fill in primary sample format with information from the track format.
int chunkUid = Ints.checkedCast(sampleQueues[sampleQueueIndex].peekSourceId());
int chunkIndex = 0;
while (chunkIndex < mediaChunks.size() && mediaChunks.get(chunkIndex).uid != chunkUid) {
chunkIndex++;
}
Format trackFormat =
chunkIndex < mediaChunks.size()
? mediaChunks.get(chunkIndex).trackFormat
: Assertions.checkNotNull(upstreamTrackFormat);
format = format.withManifestFormatInfo(trackFormat);
}
formatHolder.format = format;
}
return result;
}
public int skipData(int sampleQueueIndex, long positionUs) {
if (isPendingReset()) {
return 0;
}
SampleQueue sampleQueue = sampleQueues[sampleQueueIndex];
int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished);
// Ensure we don't skip into preload chunks until we can be sure they are permanently published.
@Nullable HlsMediaChunk lastChunk = Iterables.getLast(mediaChunks, /* defaultValue= */ null);
if (lastChunk != null && !lastChunk.isPublished()) {
int readIndex = sampleQueue.getReadIndex();
int firstSampleIndex = lastChunk.getFirstSampleIndex(sampleQueueIndex);
skipCount = min(skipCount, firstSampleIndex - readIndex);
}
sampleQueue.skip(skipCount);
return skipCount;
}
// SequenceableLoader implementation
@Override
public long getBufferedPositionUs() {
if (loadingFinished) {
return C.TIME_END_OF_SOURCE;
} else if (isPendingReset()) {
return pendingResetPositionUs;
} else {
long bufferedPositionUs = lastSeekPositionUs;
HlsMediaChunk lastMediaChunk = getLastMediaChunk();
HlsMediaChunk lastCompletedMediaChunk =
lastMediaChunk.isLoadCompleted()
? lastMediaChunk
: mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null;
if (lastCompletedMediaChunk != null) {
bufferedPositionUs = max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs);
}
if (sampleQueuesBuilt) {
for (SampleQueue sampleQueue : sampleQueues) {
bufferedPositionUs = max(bufferedPositionUs, sampleQueue.getLargestQueuedTimestampUs());
}
}
return bufferedPositionUs;
}
}
@Override
public long getNextLoadPositionUs() {
if (isPendingReset()) {
return pendingResetPositionUs;
} else {
return loadingFinished ? C.TIME_END_OF_SOURCE : getLastMediaChunk().endTimeUs;
}
}
@Override
public boolean continueLoading(long positionUs) {
if (loadingFinished || loader.isLoading() || loader.hasFatalError()) {
return false;
}
List<HlsMediaChunk> chunkQueue;
long loadPositionUs;
if (isPendingReset()) {
chunkQueue = Collections.emptyList();
loadPositionUs = pendingResetPositionUs;
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.setStartTimeUs(pendingResetPositionUs);
}
} else {
chunkQueue = readOnlyMediaChunks;
HlsMediaChunk lastMediaChunk = getLastMediaChunk();
loadPositionUs =
lastMediaChunk.isLoadCompleted()
? lastMediaChunk.endTimeUs
: max(lastSeekPositionUs, lastMediaChunk.startTimeUs);
}
nextChunkHolder.clear();
chunkSource.getNextChunk(
positionUs,
loadPositionUs,
chunkQueue,
/* allowEndOfStream= */ prepared || !chunkQueue.isEmpty(),
nextChunkHolder);
boolean endOfStream = nextChunkHolder.endOfStream;
@Nullable Chunk loadable = nextChunkHolder.chunk;
@Nullable Uri playlistUrlToLoad = nextChunkHolder.playlistUrl;
if (endOfStream) {
pendingResetPositionUs = C.TIME_UNSET;
loadingFinished = true;
return true;
}
if (loadable == null) {
if (playlistUrlToLoad != null) {
callback.onPlaylistRefreshRequired(playlistUrlToLoad);
}
return false;
}
if (isMediaChunk(loadable)) {
initMediaChunkLoad((HlsMediaChunk) loadable);
}
loadingChunk = loadable;
long elapsedRealtimeMs =
loader.startLoading(
loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type));
mediaSourceEventDispatcher.loadStarted(
new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs),
loadable.type,
trackType,
loadable.trackFormat,
loadable.trackSelectionReason,
loadable.trackSelectionData,
loadable.startTimeUs,
loadable.endTimeUs);
return true;
}
@Override
public boolean isLoading() {
return loader.isLoading();
}
@Override
public void reevaluateBuffer(long positionUs) {
if (loader.hasFatalError() || isPendingReset()) {
return;
}
if (loader.isLoading()) {
Assertions.checkNotNull(loadingChunk);
if (chunkSource.shouldCancelLoad(positionUs, loadingChunk, readOnlyMediaChunks)) {
loader.cancelLoading();
}
return;
}
int newQueueSize = readOnlyMediaChunks.size();
while (newQueueSize > 0
&& chunkSource.getChunkPublicationState(readOnlyMediaChunks.get(newQueueSize - 1))
== CHUNK_PUBLICATION_STATE_REMOVED) {
newQueueSize--;
}
if (newQueueSize < readOnlyMediaChunks.size()) {
discardUpstream(newQueueSize);
}
int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks);
if (preferredQueueSize < mediaChunks.size()) {
discardUpstream(preferredQueueSize);
}
}
// Loader.Callback implementation.
@Override
public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) {
loadingChunk = null;
chunkSource.onChunkLoadCompleted(loadable);
LoadEventInfo loadEventInfo =
new LoadEventInfo(
loadable.loadTaskId,
loadable.dataSpec,
loadable.getUri(),
loadable.getResponseHeaders(),
elapsedRealtimeMs,
loadDurationMs,
loadable.bytesLoaded());
loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
mediaSourceEventDispatcher.loadCompleted(
loadEventInfo,
loadable.type,
trackType,
loadable.trackFormat,
loadable.trackSelectionReason,
loadable.trackSelectionData,
loadable.startTimeUs,
loadable.endTimeUs);
if (!prepared) {
continueLoading(lastSeekPositionUs);
} else {
callback.onContinueLoadingRequested(this);
}
}
@Override
public void onLoadCanceled(
Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {
loadingChunk = null;
LoadEventInfo loadEventInfo =
new LoadEventInfo(
loadable.loadTaskId,
loadable.dataSpec,
loadable.getUri(),
loadable.getResponseHeaders(),
elapsedRealtimeMs,
loadDurationMs,
loadable.bytesLoaded());
loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
mediaSourceEventDispatcher.loadCanceled(
loadEventInfo,
loadable.type,
trackType,
loadable.trackFormat,
loadable.trackSelectionReason,
loadable.trackSelectionData,
loadable.startTimeUs,
loadable.endTimeUs);
if (!released) {
if (isPendingReset() || enabledTrackGroupCount == 0) {
resetSampleQueues();
}
if (enabledTrackGroupCount > 0) {
callback.onContinueLoadingRequested(this);
}
}
}
@Override
public LoadErrorAction onLoadError(
Chunk loadable,
long elapsedRealtimeMs,
long loadDurationMs,
IOException error,
int errorCount) {
boolean isMediaChunk = isMediaChunk(loadable);
if (isMediaChunk
&& !((HlsMediaChunk) loadable).isPublished()
&& error instanceof HttpDataSource.InvalidResponseCodeException) {
int responseCode = ((HttpDataSource.InvalidResponseCodeException) error).responseCode;
if (responseCode == 410 || responseCode == 404) {
// According to RFC 8216, Section 6.2.6 a server should respond with an HTTP 404 (Not found)
// for requests of hinted parts that are replaced and not available anymore. We've seen test
// streams with HTTP 410 (Gone) also.
return Loader.RETRY;
}
}
long bytesLoaded = loadable.bytesLoaded();
boolean exclusionSucceeded = false;
LoadEventInfo loadEventInfo =
new LoadEventInfo(
loadable.loadTaskId,
loadable.dataSpec,
loadable.getUri(),
loadable.getResponseHeaders(),
elapsedRealtimeMs,
loadDurationMs,
bytesLoaded);
MediaLoadData mediaLoadData =
new MediaLoadData(
loadable.type,
trackType,
loadable.trackFormat,
loadable.trackSelectionReason,
loadable.trackSelectionData,
Util.usToMs(loadable.startTimeUs),
Util.usToMs(loadable.endTimeUs));
LoadErrorInfo loadErrorInfo =
new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount);
LoadErrorAction loadErrorAction;
@Nullable
LoadErrorHandlingPolicy.FallbackSelection fallbackSelection =
loadErrorHandlingPolicy.getFallbackSelectionFor(
createFallbackOptions(chunkSource.getTrackSelection()), loadErrorInfo);
if (fallbackSelection != null
&& fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
exclusionSucceeded =
chunkSource.maybeExcludeTrack(loadable, fallbackSelection.exclusionDurationMs);
}
if (exclusionSucceeded) {
if (isMediaChunk && bytesLoaded == 0) {
HlsMediaChunk removed = mediaChunks.remove(mediaChunks.size() - 1);
Assertions.checkState(removed == loadable);
if (mediaChunks.isEmpty()) {
pendingResetPositionUs = lastSeekPositionUs;
} else {
Iterables.getLast(mediaChunks).invalidateExtractor();
}
}
loadErrorAction = Loader.DONT_RETRY;
} else /* did not exclude */ {
long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo);
loadErrorAction =
retryDelayMs != C.TIME_UNSET
? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs)
: Loader.DONT_RETRY_FATAL;
}
boolean wasCanceled = !loadErrorAction.isRetry();
mediaSourceEventDispatcher.loadError(
loadEventInfo,
loadable.type,
trackType,
loadable.trackFormat,
loadable.trackSelectionReason,
loadable.trackSelectionData,
loadable.startTimeUs,
loadable.endTimeUs,
error,
wasCanceled);
if (wasCanceled) {
loadingChunk = null;
loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
}
if (exclusionSucceeded) {
if (!prepared) {
continueLoading(lastSeekPositionUs);
} else {
callback.onContinueLoadingRequested(this);
}
}
return loadErrorAction;
}
// Called by the consuming thread, but only when there is no loading thread.
/**
* Performs initialization for a media chunk that's about to start loading.
*
* @param chunk The media chunk that's about to start loading.
*/
private void initMediaChunkLoad(HlsMediaChunk chunk) {
sourceChunk = chunk;
upstreamTrackFormat = chunk.trackFormat;
pendingResetPositionUs = C.TIME_UNSET;
mediaChunks.add(chunk);
ImmutableList.Builder<Integer> sampleQueueWriteIndicesBuilder = ImmutableList.builder();
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueueWriteIndicesBuilder.add(sampleQueue.getWriteIndex());
}
chunk.init(/* output= */ this, sampleQueueWriteIndicesBuilder.build());
for (HlsSampleQueue sampleQueue : sampleQueues) {
sampleQueue.setSourceChunk(chunk);
if (chunk.shouldSpliceIn) {
sampleQueue.splice();
}
}
}
private void discardUpstream(int preferredQueueSize) {
Assertions.checkState(!loader.isLoading());
int newQueueSize = C.LENGTH_UNSET;
for (int i = preferredQueueSize; i < mediaChunks.size(); i++) {
if (canDiscardUpstreamMediaChunksFromIndex(i)) {
newQueueSize = i;
break;
}
}
if (newQueueSize == C.LENGTH_UNSET) {
return;
}
long endTimeUs = getLastMediaChunk().endTimeUs;
HlsMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize);
if (mediaChunks.isEmpty()) {
pendingResetPositionUs = lastSeekPositionUs;
} else {
Iterables.getLast(mediaChunks).invalidateExtractor();
}
loadingFinished = false;
mediaSourceEventDispatcher.upstreamDiscarded(
primarySampleQueueType, firstRemovedChunk.startTimeUs, endTimeUs);
}
// ExtractorOutput implementation. Called by the loading thread.
@Override
public TrackOutput track(int id, int type) {
@Nullable TrackOutput trackOutput = null;
if (MAPPABLE_TYPES.contains(type)) {
// Track types in MAPPABLE_TYPES are handled manually to ignore IDs.
trackOutput = getMappedTrackOutput(id, type);
} else /* non-mappable type track */ {
for (int i = 0; i < sampleQueues.length; i++) {
if (sampleQueueTrackIds[i] == id) {
trackOutput = sampleQueues[i];
break;
}
}
}
if (trackOutput == null) {
if (tracksEnded) {
return createFakeTrackOutput(id, type);
} else {
// The relevant SampleQueue hasn't been constructed yet - so construct it.
trackOutput = createSampleQueue(id, type);
}
}
if (type == C.TRACK_TYPE_METADATA) {
if (emsgUnwrappingTrackOutput == null) {
emsgUnwrappingTrackOutput = new EmsgUnwrappingTrackOutput(trackOutput, metadataType);
}
return emsgUnwrappingTrackOutput;
}
return trackOutput;
}
/**
* Returns the {@link TrackOutput} for the provided {@code type} and {@code id}, or null if none
* has been created yet.
*
* <p>If a {@link SampleQueue} for {@code type} has been created and is mapped, but it has a
* different ID, then return a {@link DummyTrackOutput} that does nothing.
*
* <p>If a {@link SampleQueue} for {@code type} has been created but is not mapped, then map it to
* this {@code id} and return it. This situation can happen after a call to {@link
* #onNewExtractor}.
*
* @param id The ID of the track.
* @param type The type of the track, must be one of {@link #MAPPABLE_TYPES}.
* @return The mapped {@link TrackOutput}, or null if it's not been created yet.
*/
@Nullable
private TrackOutput getMappedTrackOutput(int id, int type) {
Assertions.checkArgument(MAPPABLE_TYPES.contains(type));
int sampleQueueIndex = sampleQueueIndicesByType.get(type, C.INDEX_UNSET);
if (sampleQueueIndex == C.INDEX_UNSET) {
return null;
}
if (sampleQueueMappingDoneByType.add(type)) {
sampleQueueTrackIds[sampleQueueIndex] = id;
}
return sampleQueueTrackIds[sampleQueueIndex] == id
? sampleQueues[sampleQueueIndex]
: createFakeTrackOutput(id, type);
}
private SampleQueue createSampleQueue(int id, int type) {
int trackCount = sampleQueues.length;
boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO;
HlsSampleQueue sampleQueue =
new HlsSampleQueue(allocator, drmSessionManager, drmEventDispatcher, overridingDrmInitData);
sampleQueue.setStartTimeUs(lastSeekPositionUs);
if (isAudioVideo) {
sampleQueue.setDrmInitData(drmInitData);
}
sampleQueue.setSampleOffsetUs(sampleOffsetUs);
if (sourceChunk != null) {
sampleQueue.setSourceChunk(sourceChunk);
}
sampleQueue.setUpstreamFormatChangeListener(this);
sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1);
sampleQueueTrackIds[trackCount] = id;
sampleQueues = Util.nullSafeArrayAppend(sampleQueues, sampleQueue);
sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1);
sampleQueueIsAudioVideoFlags[trackCount] = isAudioVideo;
haveAudioVideoSampleQueues |= sampleQueueIsAudioVideoFlags[trackCount];
sampleQueueMappingDoneByType.add(type);
sampleQueueIndicesByType.append(type, trackCount);
if (getTrackTypeScore(type) > getTrackTypeScore(primarySampleQueueType)) {
primarySampleQueueIndex = trackCount;
primarySampleQueueType = type;
}
sampleQueuesEnabledStates = Arrays.copyOf(sampleQueuesEnabledStates, trackCount + 1);
return sampleQueue;
}
@Override
public void endTracks() {
tracksEnded = true;
handler.post(onTracksEndedRunnable);
}
@Override
public void seekMap(SeekMap seekMap) {
// Do nothing.
}
// UpstreamFormatChangedListener implementation. Called by the loading thread.
@Override
public void onUpstreamFormatChanged(Format format) {
handler.post(maybeFinishPrepareRunnable);
}
// Called by the loading thread.
/** Called when an {@link HlsMediaChunk} starts extracting media with a new {@link Extractor}. */
public void onNewExtractor() {
sampleQueueMappingDoneByType.clear();
}
/**
* Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that
* are subsequently loaded by this wrapper.
*
* @param sampleOffsetUs The timestamp offset in microseconds.
*/
public void setSampleOffsetUs(long sampleOffsetUs) {
if (this.sampleOffsetUs != sampleOffsetUs) {
this.sampleOffsetUs = sampleOffsetUs;
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.setSampleOffsetUs(sampleOffsetUs);
}
}
}
/**
* Sets default {@link DrmInitData} for samples that are subsequently loaded by this wrapper.
*
* <p>This method should be called prior to loading each {@link HlsMediaChunk}. The {@link
* DrmInitData} passed should be that of an EXT-X-KEY tag that applies to the chunk, or {@code
* null} otherwise.
*
* <p>The final {@link DrmInitData} for subsequently queued samples is determined as followed:
*
* <ol>
* <li>It is initially set to {@code drmInitData}, unless {@code drmInitData} is null in which
* case it's set to {@link Format#drmInitData} of the upstream {@link Format}.
* <li>If the initial {@link DrmInitData} is non-null and {@link #overridingDrmInitData}
* contains an entry whose key matches the {@link DrmInitData#schemeType}, then the sample's
* {@link DrmInitData} is overridden to be this entry's value.
* </ol>
*
* <p>
*
* @param drmInitData The default {@link DrmInitData} for samples that are subsequently queued. If
* non-null then it takes precedence over {@link Format#drmInitData} of the upstream {@link
* Format}, but will still be overridden by a matching override in {@link
* #overridingDrmInitData}.
*/
public void setDrmInitData(@Nullable DrmInitData drmInitData) {
if (!Util.areEqual(this.drmInitData, drmInitData)) {
this.drmInitData = drmInitData;
for (int i = 0; i < sampleQueues.length; i++) {
if (sampleQueueIsAudioVideoFlags[i]) {
sampleQueues[i].setDrmInitData(drmInitData);
}
}
}
}
// Internal methods.
private void updateSampleStreams(@NullableType SampleStream[] streams) {
hlsSampleStreams.clear();
for (@Nullable SampleStream stream : streams) {
if (stream != null) {
hlsSampleStreams.add((HlsSampleStream) stream);
}
}
}
private boolean finishedReadingChunk(HlsMediaChunk chunk) {
int chunkUid = chunk.uid;
int sampleQueueCount = sampleQueues.length;
for (int i = 0; i < sampleQueueCount; i++) {
if (sampleQueuesEnabledStates[i] && sampleQueues[i].peekSourceId() == chunkUid) {
return false;
}
}
return true;
}
private boolean canDiscardUpstreamMediaChunksFromIndex(int mediaChunkIndex) {
for (int i = mediaChunkIndex; i < mediaChunks.size(); i++) {
if (mediaChunks.get(i).shouldSpliceIn) {
// Discarding not possible because a spliced-in chunk potentially removed sample metadata
// from the previous chunks.
// TODO: Keep sample metadata to allow restoring these chunks [internal b/159904763].
return false;
}
}
HlsMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex);
for (int i = 0; i < sampleQueues.length; i++) {
int discardFromIndex = mediaChunk.getFirstSampleIndex(/* sampleQueueIndex= */ i);
if (sampleQueues[i].getReadIndex() > discardFromIndex) {
// Discarding not possible because we already read from the chunk.
// TODO: Sparse tracks (e.g. ID3) may prevent discarding in almost all cases because it
// means that most chunks have been read from already. See [internal b/161126666].
return false;
}
}
return true;
}
private HlsMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) {
HlsMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex);
Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size());
for (int i = 0; i < sampleQueues.length; i++) {
int discardFromIndex = firstRemovedChunk.getFirstSampleIndex(/* sampleQueueIndex= */ i);
sampleQueues[i].discardUpstreamSamples(discardFromIndex);
}
return firstRemovedChunk;
}
private void resetSampleQueues() {
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.reset(pendingResetUpstreamFormats);
}
pendingResetUpstreamFormats = false;
}
private void onTracksEnded() {
sampleQueuesBuilt = true;
maybeFinishPrepare();
}
private void maybeFinishPrepare() {
if (released || trackGroupToSampleQueueIndex != null || !sampleQueuesBuilt) {
return;
}
for (SampleQueue sampleQueue : sampleQueues) {
if (sampleQueue.getUpstreamFormat() == null) {
return;
}
}
if (trackGroups != null) {
// The track groups were created with multivariant playlist information. They only need to be
// mapped to a sample queue.
mapSampleQueuesToMatchTrackGroups();
} else {
// Tracks are created using media segment information.
buildTracksFromSampleStreams();
setIsPrepared();
callback.onPrepared();
}
}
@RequiresNonNull("trackGroups")
@EnsuresNonNull("trackGroupToSampleQueueIndex")
private void mapSampleQueuesToMatchTrackGroups() {
int trackGroupCount = trackGroups.length;
trackGroupToSampleQueueIndex = new int[trackGroupCount];
Arrays.fill(trackGroupToSampleQueueIndex, C.INDEX_UNSET);
for (int i = 0; i < trackGroupCount; i++) {
for (int queueIndex = 0; queueIndex < sampleQueues.length; queueIndex++) {
SampleQueue sampleQueue = sampleQueues[queueIndex];
Format upstreamFormat = Assertions.checkStateNotNull(sampleQueue.getUpstreamFormat());
if (formatsMatch(upstreamFormat, trackGroups.get(i).getFormat(0))) {
trackGroupToSampleQueueIndex[i] = queueIndex;
break;
}
}
}
for (HlsSampleStream sampleStream : hlsSampleStreams) {
sampleStream.bindSampleQueue();
}
}
/**
* Builds tracks that are exposed by this {@link HlsSampleStreamWrapper} instance, as well as
* internal data-structures required for operation.
*
* <p>Tracks in HLS are complicated. A HLS multivariant playlist contains a number of "variants".
* Each variant stream typically contains muxed video, audio and (possibly) additional audio,
* metadata and caption tracks. We wish to allow the user to select between an adaptive track that
* spans all variants, as well as each individual variant. If multiple audio tracks are present
* within each variant then we wish to allow the user to select between those also.
*
* <p>To do this, tracks are constructed as follows. The {@link HlsChunkSource} exposes (N+1)
* tracks, where N is the number of variants defined in the HLS multivariant playlist. These
* consist of one adaptive track defined to span all variants and a track for each individual
* variant. The adaptive track is initially selected. The extractor is then prepared to discover
* the tracks inside of each variant stream. The two sets of tracks are then combined by this
* method to create a third set, which is the set exposed by this {@link HlsSampleStreamWrapper}:
*
* <ul>
* <li>The extractor tracks are inspected to infer a "primary" track type. If a video track is
* present then it is always the primary type. If not, audio is the primary type if present.
* Else text is the primary type if present. Else there is no primary type.
* <li>If there is exactly one extractor track of the primary type, it's expanded into (N+1)
* exposed tracks, all of which correspond to the primary extractor track and each of which
* corresponds to a different chunk source track. Selecting one of these tracks has the
* effect of switching the selected track on the chunk source.
* <li>All other extractor tracks are exposed directly. Selecting one of these tracks has the
* effect of selecting an extractor track, leaving the selected track on the chunk source
* unchanged.
* </ul>
*/
@EnsuresNonNull({"trackGroups", "optionalTrackGroups", "trackGroupToSampleQueueIndex"})
private void buildTracksFromSampleStreams() {
// Iterate through the extractor tracks to discover the "primary" track type, and the index
// of the single track of this type.
int primaryExtractorTrackType = C.TRACK_TYPE_NONE;
int primaryExtractorTrackIndex = C.INDEX_UNSET;
int extractorTrackCount = sampleQueues.length;
for (int i = 0; i < extractorTrackCount; i++) {
@Nullable
String sampleMimeType =
Assertions.checkStateNotNull(sampleQueues[i].getUpstreamFormat()).sampleMimeType;
int trackType;
if (MimeTypes.isVideo(sampleMimeType)) {
trackType = C.TRACK_TYPE_VIDEO;
} else if (MimeTypes.isAudio(sampleMimeType)) {
trackType = C.TRACK_TYPE_AUDIO;
} else if (MimeTypes.isText(sampleMimeType)) {
trackType = C.TRACK_TYPE_TEXT;
} else {
trackType = C.TRACK_TYPE_NONE;
}
if (getTrackTypeScore(trackType) > getTrackTypeScore(primaryExtractorTrackType)) {
primaryExtractorTrackType = trackType;
primaryExtractorTrackIndex = i;
} else if (trackType == primaryExtractorTrackType
&& primaryExtractorTrackIndex != C.INDEX_UNSET) {
// We have multiple tracks of the primary type. We only want an index if there only exists a
// single track of the primary type, so unset the index again.
primaryExtractorTrackIndex = C.INDEX_UNSET;
}
}
TrackGroup chunkSourceTrackGroup = chunkSource.getTrackGroup();
int chunkSourceTrackCount = chunkSourceTrackGroup.length;
// Instantiate the necessary internal data-structures.
primaryTrackGroupIndex = C.INDEX_UNSET;
trackGroupToSampleQueueIndex = new int[extractorTrackCount];
for (int i = 0; i < extractorTrackCount; i++) {
trackGroupToSampleQueueIndex[i] = i;
}
// Construct the set of exposed track groups.
TrackGroup[] trackGroups = new TrackGroup[extractorTrackCount];
for (int i = 0; i < extractorTrackCount; i++) {
Format sampleFormat = Assertions.checkStateNotNull(sampleQueues[i].getUpstreamFormat());
if (i == primaryExtractorTrackIndex) {
Format[] formats = new Format[chunkSourceTrackCount];
for (int j = 0; j < chunkSourceTrackCount; j++) {
Format playlistFormat = chunkSourceTrackGroup.getFormat(j);
if (primaryExtractorTrackType == C.TRACK_TYPE_AUDIO && muxedAudioFormat != null) {
playlistFormat = playlistFormat.withManifestFormatInfo(muxedAudioFormat);
}
// If there's only a single variant (chunkSourceTrackCount == 1) then we can safely
// retain all fields from sampleFormat. Else we need to use deriveFormat to retain only
// the fields that will be the same for all variants.
formats[j] =
chunkSourceTrackCount == 1
? sampleFormat.withManifestFormatInfo(playlistFormat)
: deriveFormat(playlistFormat, sampleFormat, /* propagateBitrates= */ true);
}
trackGroups[i] = new TrackGroup(uid, formats);
primaryTrackGroupIndex = i;
} else {
@Nullable
Format playlistFormat =
primaryExtractorTrackType == C.TRACK_TYPE_VIDEO
&& MimeTypes.isAudio(sampleFormat.sampleMimeType)
? muxedAudioFormat
: null;
String muxedTrackGroupId = uid + ":muxed:" + (i < primaryExtractorTrackIndex ? i : i - 1);
trackGroups[i] =
new TrackGroup(
muxedTrackGroupId,
deriveFormat(playlistFormat, sampleFormat, /* propagateBitrates= */ false));
}
}
this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups);
Assertions.checkState(optionalTrackGroups == null);
optionalTrackGroups = Collections.emptySet();
}
private TrackGroupArray createTrackGroupArrayWithDrmInfo(TrackGroup[] trackGroups) {
for (int i = 0; i < trackGroups.length; i++) {
TrackGroup trackGroup = trackGroups[i];
Format[] exposedFormats = new Format[trackGroup.length];
for (int j = 0; j < trackGroup.length; j++) {
Format format = trackGroup.getFormat(j);
exposedFormats[j] = format.copyWithCryptoType(drmSessionManager.getCryptoType(format));
}
trackGroups[i] = new TrackGroup(trackGroup.id, exposedFormats);
}
return new TrackGroupArray(trackGroups);
}
private HlsMediaChunk getLastMediaChunk() {
return mediaChunks.get(mediaChunks.size() - 1);
}
private boolean isPendingReset() {
return pendingResetPositionUs != C.TIME_UNSET;
}
/**
* Attempts to seek to the specified position within the sample queues.
*
* @param positionUs The seek position in microseconds.
* @return Whether the in-buffer seek was successful.
*/
private boolean seekInsideBufferUs(long positionUs) {
int sampleQueueCount = sampleQueues.length;
for (int i = 0; i < sampleQueueCount; i++) {
SampleQueue sampleQueue = sampleQueues[i];
boolean seekInsideQueue = sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false);
// If we have AV tracks then an in-queue seek is successful if the seek into every AV queue
// is successful. We ignore whether seeks within non-AV queues are successful in this case, as
// they may be sparse or poorly interleaved. If we only have non-AV tracks then a seek is
// successful only if the seek into every queue succeeds.
if (!seekInsideQueue && (sampleQueueIsAudioVideoFlags[i] || !haveAudioVideoSampleQueues)) {
return false;
}
}
return true;
}
@RequiresNonNull({"trackGroups", "optionalTrackGroups"})
private void setIsPrepared() {
prepared = true;
}
@EnsuresNonNull({"trackGroups", "optionalTrackGroups"})
private void assertIsPrepared() {
Assertions.checkState(prepared);
Assertions.checkNotNull(trackGroups);
Assertions.checkNotNull(optionalTrackGroups);
}
/**
* Scores a track type. Where multiple tracks are muxed into a container, the track with the
* highest score is the primary track.
*
* @param trackType The track type.
* @return The score.
*/
private static int getTrackTypeScore(int trackType) {
switch (trackType) {
case C.TRACK_TYPE_VIDEO:
return 3;
case C.TRACK_TYPE_AUDIO:
return 2;
case C.TRACK_TYPE_TEXT:
return 1;
default:
return 0;
}
}
/**
* Derives a track sample format from the corresponding format in the multivariant playlist, and a
* sample format that may have been obtained from a chunk belonging to a different track in the
* same track group.
*
* <p>Note: Since the sample format may have been obtained from a chunk belonging to a different
* track, it should not be used as a source for data that may vary between tracks.
*
* @param playlistFormat The format information obtained from the multivariant playlist.
* @param sampleFormat The format information obtained from samples within a chunk. The chunk may
* belong to a different track in the same track group.
* @param propagateBitrates Whether the bitrates from the playlist format should be included in
* the derived format.
* @return The derived track format.
*/
private static Format deriveFormat(
@Nullable Format playlistFormat, Format sampleFormat, boolean propagateBitrates) {
if (playlistFormat == null) {
return sampleFormat;
}
int sampleTrackType = MimeTypes.getTrackType(sampleFormat.sampleMimeType);
@Nullable String sampleMimeType;
@Nullable String codecs;
if (Util.getCodecCountOfType(playlistFormat.codecs, sampleTrackType) == 1) {
// We can unequivocally map this track to a playlist variant because only one codec string
// matches this track's type.
codecs = Util.getCodecsOfType(playlistFormat.codecs, sampleTrackType);
sampleMimeType = MimeTypes.getMediaMimeType(codecs);
} else {
// The variant assigns more than one codec string to this track. We choose whichever codec
// string matches the sample mime type. This can happen when different languages are encoded
// using different codecs.
codecs =
MimeTypes.getCodecsCorrespondingToMimeType(
playlistFormat.codecs, sampleFormat.sampleMimeType);
sampleMimeType = sampleFormat.sampleMimeType;
}
Format.Builder formatBuilder =
sampleFormat
.buildUpon()
.setId(playlistFormat.id)
.setLabel(playlistFormat.label)
.setLanguage(playlistFormat.language)
.setSelectionFlags(playlistFormat.selectionFlags)
.setRoleFlags(playlistFormat.roleFlags)
.setAverageBitrate(propagateBitrates ? playlistFormat.averageBitrate : Format.NO_VALUE)
.setPeakBitrate(propagateBitrates ? playlistFormat.peakBitrate : Format.NO_VALUE)
.setCodecs(codecs);
if (sampleTrackType == C.TRACK_TYPE_VIDEO) {
formatBuilder
.setWidth(playlistFormat.width)
.setHeight(playlistFormat.height)
.setFrameRate(playlistFormat.frameRate);
}
if (sampleMimeType != null) {
formatBuilder.setSampleMimeType(sampleMimeType);
}
if (playlistFormat.channelCount != Format.NO_VALUE && sampleTrackType == C.TRACK_TYPE_AUDIO) {
formatBuilder.setChannelCount(playlistFormat.channelCount);
}
if (playlistFormat.metadata != null) {
Metadata metadata = playlistFormat.metadata;
if (sampleFormat.metadata != null) {
metadata = sampleFormat.metadata.copyWithAppendedEntriesFrom(metadata);
}
formatBuilder.setMetadata(metadata);
}
return formatBuilder.build();
}
private static boolean isMediaChunk(Chunk chunk) {
return chunk instanceof HlsMediaChunk;
}
private static boolean formatsMatch(Format manifestFormat, Format sampleFormat) {
@Nullable String manifestFormatMimeType = manifestFormat.sampleMimeType;
@Nullable String sampleFormatMimeType = sampleFormat.sampleMimeType;
int manifestFormatTrackType = MimeTypes.getTrackType(manifestFormatMimeType);
if (manifestFormatTrackType != C.TRACK_TYPE_TEXT) {
return manifestFormatTrackType == MimeTypes.getTrackType(sampleFormatMimeType);
} else if (!Util.areEqual(manifestFormatMimeType, sampleFormatMimeType)) {
return false;
}
if (MimeTypes.APPLICATION_CEA608.equals(manifestFormatMimeType)
|| MimeTypes.APPLICATION_CEA708.equals(manifestFormatMimeType)) {
return manifestFormat.accessibilityChannel == sampleFormat.accessibilityChannel;
}
return true;
}
private static DummyTrackOutput createFakeTrackOutput(int id, int type) {
Log.w(TAG, "Unmapped track with id " + id + " of type " + type);
return new DummyTrackOutput();
}
/**
* A {@link SampleQueue} that adds HLS specific functionality:
*
* <ul>
* <li>Detection of spurious discontinuities, by checking sample timestamps against the range
* expected for the currently loading chunk.
* <li>Stripping private timestamp metadata from {@link Format Formats} to avoid an excessive
* number of format switches in the queue.
* <li>Overriding of {@link Format#drmInitData}.
* </ul>
*/
private static final class HlsSampleQueue extends SampleQueue {
// TODO: Uncomment this to reject samples with unexpected timestamps. See
// https://github.com/google/ExoPlayer/issues/7030.
// /**
// * The fraction of the chunk duration from which timestamps of samples loaded from within a
// * chunk are allowed to deviate from the expected range.
// */
// private static final double MAX_TIMESTAMP_DEVIATION_FRACTION = 0.5;
//
// /**
// * A minimum tolerance for sample timestamps in microseconds. Timestamps of samples loaded
// * from within a chunk are always allowed to deviate up to this amount from the expected
// * range.
// */
// private static final long MIN_TIMESTAMP_DEVIATION_TOLERANCE_US = 4_000_000;
//
// @Nullable private HlsMediaChunk sourceChunk;
// private long sourceChunkLastSampleTimeUs;
// private long minAllowedSampleTimeUs;
// private long maxAllowedSampleTimeUs;
private final Map<String, DrmInitData> overridingDrmInitData;
@Nullable private DrmInitData drmInitData;
private HlsSampleQueue(
Allocator allocator,
DrmSessionManager drmSessionManager,
DrmSessionEventListener.EventDispatcher eventDispatcher,
Map<String, DrmInitData> overridingDrmInitData) {
super(allocator, drmSessionManager, eventDispatcher);
this.overridingDrmInitData = overridingDrmInitData;
}
public void setSourceChunk(HlsMediaChunk chunk) {
sourceId(chunk.uid);
// TODO: Uncomment this to reject samples with unexpected timestamps. See
// https://github.com/google/ExoPlayer/issues/7030.
// sourceChunk = chunk;
// sourceChunkLastSampleTimeUs = C.TIME_UNSET;
// long allowedDeviationUs =
// Math.max(
// (long) ((chunk.endTimeUs - chunk.startTimeUs) * MAX_TIMESTAMP_DEVIATION_FRACTION),
// MIN_TIMESTAMP_DEVIATION_TOLERANCE_US);
// minAllowedSampleTimeUs = chunk.startTimeUs - allowedDeviationUs;
// maxAllowedSampleTimeUs = chunk.endTimeUs + allowedDeviationUs;
}
public void setDrmInitData(@Nullable DrmInitData drmInitData) {
this.drmInitData = drmInitData;
invalidateUpstreamFormatAdjustment();
}
@SuppressWarnings("ReferenceEquality")
@Override
public Format getAdjustedUpstreamFormat(Format format) {
@Nullable
DrmInitData drmInitData = this.drmInitData != null ? this.drmInitData : format.drmInitData;
if (drmInitData != null) {
@Nullable
DrmInitData overridingDrmInitData = this.overridingDrmInitData.get(drmInitData.schemeType);
if (overridingDrmInitData != null) {
drmInitData = overridingDrmInitData;
}
}
@Nullable Metadata metadata = getAdjustedMetadata(format.metadata);
if (drmInitData != format.drmInitData || metadata != format.metadata) {
format = format.buildUpon().setDrmInitData(drmInitData).setMetadata(metadata).build();
}
return super.getAdjustedUpstreamFormat(format);
}
/**
* Strips the private timestamp frame from metadata, if present. See:
* https://github.com/google/ExoPlayer/issues/5063
*/
@Nullable
private Metadata getAdjustedMetadata(@Nullable Metadata metadata) {
if (metadata == null) {
return null;
}
int length = metadata.length();
int transportStreamTimestampMetadataIndex = C.INDEX_UNSET;
for (int i = 0; i < length; i++) {
Metadata.Entry metadataEntry = metadata.get(i);
if (metadataEntry instanceof PrivFrame) {
PrivFrame privFrame = (PrivFrame) metadataEntry;
if (HlsMediaChunk.PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) {
transportStreamTimestampMetadataIndex = i;
break;
}
}
}
if (transportStreamTimestampMetadataIndex == C.INDEX_UNSET) {
return metadata;
}
if (length == 1) {
return null;
}
Metadata.Entry[] newMetadataEntries = new Metadata.Entry[length - 1];
for (int i = 0; i < length; i++) {
if (i != transportStreamTimestampMetadataIndex) {
int newIndex = i < transportStreamTimestampMetadataIndex ? i : i - 1;
newMetadataEntries[newIndex] = metadata.get(i);
}
}
return new Metadata(newMetadataEntries);
}
@Override
public void sampleMetadata(
long timeUs,
@C.BufferFlags int flags,
int size,
int offset,
@Nullable CryptoData cryptoData) {
// TODO: Uncomment this to reject samples with unexpected timestamps. See
// https://github.com/google/ExoPlayer/issues/7030.
// if (timeUs < minAllowedSampleTimeUs || timeUs > maxAllowedSampleTimeUs) {
// Util.sneakyThrow(
// new UnexpectedSampleTimestampException(
// sourceChunk, sourceChunkLastSampleTimeUs, timeUs));
// }
// sourceChunkLastSampleTimeUs = timeUs;
super.sampleMetadata(timeUs, flags, size, offset, cryptoData);
}
}
private static class EmsgUnwrappingTrackOutput implements TrackOutput {
// TODO: Create a Formats util class with common constants like this.
private static final Format ID3_FORMAT =
new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_ID3).build();
private static final Format EMSG_FORMAT =
new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_EMSG).build();
private final EventMessageDecoder emsgDecoder;
private final TrackOutput delegate;
private final Format delegateFormat;
private @MonotonicNonNull Format format;
private byte[] buffer;
private int bufferPosition;
public EmsgUnwrappingTrackOutput(
TrackOutput delegate, @HlsMediaSource.MetadataType int metadataType) {
this.emsgDecoder = new EventMessageDecoder();
this.delegate = delegate;
switch (metadataType) {
case HlsMediaSource.METADATA_TYPE_ID3:
delegateFormat = ID3_FORMAT;
break;
case HlsMediaSource.METADATA_TYPE_EMSG:
delegateFormat = EMSG_FORMAT;
break;
default:
throw new IllegalArgumentException("Unknown metadataType: " + metadataType);
}
this.buffer = new byte[0];
this.bufferPosition = 0;
}
@Override
public void format(Format format) {
this.format = format;
delegate.format(delegateFormat);
}
@Override
public int sampleData(
DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
throws IOException {
ensureBufferCapacity(bufferPosition + length);
int numBytesRead = input.read(buffer, bufferPosition, length);
if (numBytesRead == C.RESULT_END_OF_INPUT) {
if (allowEndOfInput) {
return C.RESULT_END_OF_INPUT;
} else {
throw new EOFException();
}
}
bufferPosition += numBytesRead;
return numBytesRead;
}
@Override
public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
ensureBufferCapacity(bufferPosition + length);
data.readBytes(this.buffer, bufferPosition, length);
bufferPosition += length;
}
@Override
public void sampleMetadata(
long timeUs,
@C.BufferFlags int flags,
int size,
int offset,
@Nullable CryptoData cryptoData) {
Assertions.checkNotNull(format);
ParsableByteArray sample = getSampleAndTrimBuffer(size, offset);
ParsableByteArray sampleForDelegate;
if (Util.areEqual(format.sampleMimeType, delegateFormat.sampleMimeType)) {
// Incoming format matches delegate track's format, so pass straight through.
sampleForDelegate = sample;
} else if (MimeTypes.APPLICATION_EMSG.equals(format.sampleMimeType)) {
// Incoming sample is EMSG, and delegate track is not expecting EMSG, so try unwrapping.
EventMessage emsg = emsgDecoder.decode(sample);
if (!emsgContainsExpectedWrappedFormat(emsg)) {
Log.w(
TAG,
String.format(
"Ignoring EMSG. Expected it to contain wrapped %s but actual wrapped format: %s",
delegateFormat.sampleMimeType, emsg.getWrappedMetadataFormat()));
return;
}
sampleForDelegate =
new ParsableByteArray(Assertions.checkNotNull(emsg.getWrappedMetadataBytes()));
} else {
Log.w(TAG, "Ignoring sample for unsupported format: " + format.sampleMimeType);
return;
}
int sampleSize = sampleForDelegate.bytesLeft();
delegate.sampleData(sampleForDelegate, sampleSize);
delegate.sampleMetadata(timeUs, flags, sampleSize, offset, cryptoData);
}
private boolean emsgContainsExpectedWrappedFormat(EventMessage emsg) {
@Nullable Format wrappedMetadataFormat = emsg.getWrappedMetadataFormat();
return wrappedMetadataFormat != null
&& Util.areEqual(delegateFormat.sampleMimeType, wrappedMetadataFormat.sampleMimeType);
}
private void ensureBufferCapacity(int requiredLength) {
if (buffer.length < requiredLength) {
buffer = Arrays.copyOf(buffer, requiredLength + requiredLength / 2);
}
}
/**
* Removes a complete sample from the {@link #buffer} field & reshuffles the tail data skipped
* by {@code offset} to the head of the array.
*
* @param size see {@code size} param of {@link #sampleMetadata}.
* @param offset see {@code offset} param of {@link #sampleMetadata}.
* @return A {@link ParsableByteArray} containing the sample removed from {@link #buffer}.
*/
private ParsableByteArray getSampleAndTrimBuffer(int size, int offset) {
int sampleEnd = bufferPosition - offset;
int sampleStart = sampleEnd - size;
byte[] sampleBytes = Arrays.copyOfRange(buffer, sampleStart, sampleEnd);
ParsableByteArray sample = new ParsableByteArray(sampleBytes);
System.arraycopy(buffer, sampleEnd, buffer, 0, offset);
bufferPosition = offset;
return sample;
}
}
}