Add a drop-in replacement for MediaExtractor in Media3

This change introduces a new class in Media3 `MediaExtractorCompat`, designed to be a drop-in replacement for platform `MediaExtractor`. While not all APIs are currently supported, the core functionality for the most common use cases of `MediaExtractor` is now available. Full API compatibility will be achieved in the future.

PiperOrigin-RevId: 643045429
This commit is contained in:
rohks 2024-06-13 10:30:44 -07:00 committed by Copybara-Service
parent e985603e0a
commit df5352752f
3 changed files with 1329 additions and 0 deletions

View File

@ -0,0 +1,727 @@
/*
* 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;
import static androidx.annotation.VisibleForTesting.NONE;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.exoplayer.source.SampleStream.FLAG_OMIT_SAMPLE_DATA;
import static androidx.media3.exoplayer.source.SampleStream.FLAG_PEEK;
import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT;
import android.content.Context;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import android.util.SparseArray;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.MediaFormatUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSourceUtil;
import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;
import androidx.media3.exoplayer.source.SampleQueue;
import androidx.media3.exoplayer.source.SampleStream.ReadDataResult;
import androidx.media3.exoplayer.source.SampleStream.ReadFlags;
import androidx.media3.exoplayer.source.UnrecognizedInputFormatException;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.DefaultAllocator;
import androidx.media3.extractor.DefaultExtractorInput;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.DummyTrackOutput;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.Extractor.ReadResult;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.SeekMap.SeekPoints;
import androidx.media3.extractor.SeekPoint;
import androidx.media3.extractor.TrackOutput;
import androidx.media3.extractor.mp4.Mp4Extractor;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import java.io.EOFException;
import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
/**
* A drop-in replacement for {@link MediaExtractor} that provides similar functionality, based on
* the {@code media3.extractor} logic.
*/
@UnstableApi
public final class MediaExtractorCompat {
/**
* The seeking mode. One of {@link #SEEK_TO_PREVIOUS_SYNC}, {@link #SEEK_TO_NEXT_SYNC}, or {@link
* #SEEK_TO_CLOSEST_SYNC}.
*/
@IntDef({
SEEK_TO_PREVIOUS_SYNC,
SEEK_TO_NEXT_SYNC,
SEEK_TO_CLOSEST_SYNC,
})
@Retention(RetentionPolicy.SOURCE)
public @interface SeekMode {}
/** See {@link MediaExtractor#SEEK_TO_PREVIOUS_SYNC}. */
public static final int SEEK_TO_PREVIOUS_SYNC = MediaExtractor.SEEK_TO_PREVIOUS_SYNC;
/** See {@link MediaExtractor#SEEK_TO_NEXT_SYNC}. */
public static final int SEEK_TO_NEXT_SYNC = MediaExtractor.SEEK_TO_NEXT_SYNC;
/** See {@link MediaExtractor#SEEK_TO_CLOSEST_SYNC}. */
public static final int SEEK_TO_CLOSEST_SYNC = MediaExtractor.SEEK_TO_CLOSEST_SYNC;
private static final String TAG = "MediaExtractorCompat";
private final ExtractorsFactory extractorsFactory;
private final DataSource.Factory dataSourceFactory;
private final PositionHolder positionHolder;
private final Allocator allocator;
private final ArrayList<MediaExtractorTrack> tracks;
private final SparseArray<MediaExtractorSampleQueue> sampleQueues;
private final ArrayDeque<Integer> trackIndicesPerSampleInQueuedOrder;
private final FormatHolder formatHolder;
private final DecoderInputBuffer sampleHolder;
private final DecoderInputBuffer noDataBuffer;
private final Set<Integer> selectedTrackIndices;
private boolean hasBeenPrepared;
private long offsetInCurrentFile;
@Nullable private Extractor currentExtractor;
@Nullable private ExtractorInput currentExtractorInput;
@Nullable private DataSource currentDataSource;
@Nullable private SeekPoint pendingSeek;
@Nullable private SeekMap seekMap;
private boolean tracksEnded;
private int upstreamFormatsCount;
/** Creates a new instance. */
public MediaExtractorCompat(Context context) {
this(new DefaultExtractorsFactory(), new DefaultDataSource.Factory(context));
}
/**
* Creates a new instance using the given {@link ExtractorsFactory extractorsFactory} to create
* the {@link Extractor extractors} to use for obtaining media samples from a DataSource generated
* by the given {@link DataSource.Factory dataSourceFactory}.
*/
public MediaExtractorCompat(
ExtractorsFactory extractorsFactory, DataSource.Factory dataSourceFactory) {
this.extractorsFactory = extractorsFactory;
this.dataSourceFactory = dataSourceFactory;
positionHolder = new PositionHolder();
allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
tracks = new ArrayList<>();
sampleQueues = new SparseArray<>();
trackIndicesPerSampleInQueuedOrder = new ArrayDeque<>();
formatHolder = new FormatHolder();
sampleHolder = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED);
noDataBuffer = DecoderInputBuffer.newNoDataInstance();
selectedTrackIndices = new HashSet<>();
}
/**
* Initializes the internal state with the media stream obtained from the given {@code uri} at the
* given {@code offset}.
*
* @param uri The content {@link Uri} to extract from.
* @param offset The offset into the file where the data to be extracted starts, in bytes.
* @throws IOException If an error occurs while extracting the media.
* @throws UnrecognizedInputFormatException If none of the available extractors successfully
* sniffs the input.
* @throws IllegalStateException If this method is called twice on the same instance.
*/
public void setDataSource(Uri uri, long offset) throws IOException {
// Assert that this instance is not being re-prepared, which is not currently supported.
Assertions.checkState(!hasBeenPrepared);
hasBeenPrepared = true;
offsetInCurrentFile = offset;
DataSpec dataSpec = buildDataSpec(uri, /* position= */ offsetInCurrentFile);
currentDataSource = dataSourceFactory.createDataSource();
long length = currentDataSource.open(dataSpec);
ExtractorInput currentExtractorInput =
new DefaultExtractorInput(currentDataSource, /* position= */ 0, length);
Extractor currentExtractor = selectExtractor(currentExtractorInput);
currentExtractor.init(new ExtractorOutputImpl());
boolean preparing = true;
Throwable error = null;
while (preparing) {
int result;
try {
result = currentExtractor.read(currentExtractorInput, positionHolder);
} catch (Exception | OutOfMemoryError e) {
// This value is ignored but initializes result to avoid static analysis errors.
result = Extractor.RESULT_END_OF_INPUT;
error = e;
}
preparing = !tracksEnded || upstreamFormatsCount < sampleQueues.size() || seekMap == null;
if (error != null || (preparing && result == Extractor.RESULT_END_OF_INPUT)) {
// TODO(b/178501820): Support files with incomplete track information.
release(); // Release resources as soon as possible, in case we are low on memory.
String message =
error != null
? "Exception encountered while parsing input media."
: "Reached end of input before preparation completed.";
throw ParserException.createForMalformedContainer(message, /* cause= */ error);
} else if (result == Extractor.RESULT_SEEK) {
currentExtractorInput = reopenCurrentDataSource(positionHolder.position);
}
}
this.currentExtractorInput = currentExtractorInput;
this.currentExtractor = currentExtractor;
// At this point, we know how many tracks we have, and their format.
}
/**
* Releases any resources held by this instance.
*
* <p>Note: Make sure you call this when you're done to free up any resources instead of relying
* on the garbage collector to do this for you at some point in the future.
*/
public void release() {
for (int i = 0; i < sampleQueues.size(); i++) {
sampleQueues.valueAt(i).release();
}
sampleQueues.clear();
if (currentExtractor != null) {
currentExtractor.release();
currentExtractor = null;
}
currentExtractorInput = null;
pendingSeek = null;
DataSourceUtil.closeQuietly(currentDataSource);
currentDataSource = null;
}
/** Returns the number of tracks found in the data source. */
public int getTrackCount() {
return tracks.size();
}
/** Returns the track {@link MediaFormat} at the specified {@code trackIndex}. */
public MediaFormat getTrackFormat(int trackIndex) {
return tracks.get(trackIndex).createDownstreamMediaFormat(formatHolder, noDataBuffer);
}
/**
* Selects a track at the specified {@code trackIndex}.
*
* <p>Subsequent calls to {@link #readSampleData}, {@link #getSampleTrackIndex} and {@link
* #getSampleTime} only retrieve information for the subset of tracks selected.
*
* <p>Note: Selecting the same track multiple times has no effect, the track is only selected
* once.
*/
public void selectTrack(int trackIndex) {
selectedTrackIndices.add(trackIndex);
}
/**
* Unselects the track at the specified {@code trackIndex}.
*
* <p>Subsequent calls to {@link #readSampleData}, {@link #getSampleTrackIndex} and {@link
* #getSampleTime} only retrieve information for the subset of tracks selected.
*/
public void unselectTrack(int trackIndex) {
selectedTrackIndices.remove(trackIndex);
}
/**
* All selected tracks seek near the requested {@code timeUs} according to the specified {@code
* mode}.
*/
public void seekTo(long timeUs, @SeekMode int mode) {
if (seekMap == null) {
return;
}
SeekPoints seekPoints;
if (selectedTrackIndices.size() == 1 && currentExtractor instanceof Mp4Extractor) {
// Mp4Extractor supports seeking within a specific track. This helps with poorly interleaved
// tracks. See b/223910395.
seekPoints =
((Mp4Extractor) currentExtractor)
.getSeekPoints(
timeUs, tracks.get(selectedTrackIndices.iterator().next()).getIdOfBackingTrack());
} else {
seekPoints = seekMap.getSeekPoints(timeUs);
}
SeekPoint seekPoint;
switch (mode) {
case SEEK_TO_CLOSEST_SYNC:
seekPoint =
Math.abs(timeUs - seekPoints.second.timeUs) < Math.abs(timeUs - seekPoints.first.timeUs)
? seekPoints.second
: seekPoints.first;
break;
case SEEK_TO_NEXT_SYNC:
seekPoint = seekPoints.second;
break;
case SEEK_TO_PREVIOUS_SYNC:
seekPoint = seekPoints.first;
break;
default:
// Should never happen.
throw new IllegalArgumentException();
}
trackIndicesPerSampleInQueuedOrder.clear();
for (int i = 0; i < sampleQueues.size(); i++) {
sampleQueues.valueAt(i).reset();
}
pendingSeek = seekPoint;
}
/**
* Advances to the next sample. Returns {@code false} if no more sample data is available (i.e.,
* end of stream), or {@code true} otherwise.
*
* <p>Note: When extracting from a local file, the behavior of {@link #advance} and {@link
* #readSampleData} is undefined if there are concurrent writes to the same file. This may result
* in an unexpected end of stream being signaled.
*/
public boolean advance() {
// Ensure there is a sample to discard.
if (!advanceToSampleOrEndOfInput()) {
// The end of input has been reached.
return false;
}
skipOneSample();
return advanceToSampleOrEndOfInput();
}
/**
* Retrieves the current encoded sample and stores it in the byte {@code buffer} starting at the
* given {@code offset}.
*
* <p><b>Note:</b>On success, the position and limit of {@code buffer} is updated to point to the
* data just read.
*
* @param buffer the destination byte buffer.
* @param offset The offset into the byte buffer at which to write.
* @return the sample size, or -1 if no more samples are available.
*/
public int readSampleData(ByteBuffer buffer, int offset) {
if (!advanceToSampleOrEndOfInput()) {
return -1;
}
// The platform media extractor implementation ignores the buffer's input position and limit.
buffer.position(offset);
buffer.limit(buffer.capacity());
sampleHolder.data = buffer;
peekNextSelectedTrackSample(sampleHolder, /* omitSampleData= */ false);
buffer.flip();
buffer.position(offset);
sampleHolder.data = null;
return buffer.remaining();
}
/**
* Returns the track index the current sample originates from, or -1 if no more samples are
* available.
*/
public int getSampleTrackIndex() {
if (!advanceToSampleOrEndOfInput()) {
return -1;
}
return trackIndicesPerSampleInQueuedOrder.peekFirst();
}
/**
* Returns the current sample's presentation time in microseconds, or -1 if no more samples are
* available.
*/
public long getSampleTime() {
if (!advanceToSampleOrEndOfInput()) {
return -1;
}
peekNextSelectedTrackSample(noDataBuffer, /* omitSampleData= */ true);
return noDataBuffer.timeUs;
}
/** Returns the current sample's flags. */
public int getSampleFlags() {
if (!advanceToSampleOrEndOfInput()) {
return -1;
}
peekNextSelectedTrackSample(noDataBuffer, /* omitSampleData= */ true);
int flags = 0;
flags |= noDataBuffer.isEncrypted() ? MediaExtractor.SAMPLE_FLAG_ENCRYPTED : 0;
flags |= noDataBuffer.isKeyFrame() ? MediaExtractor.SAMPLE_FLAG_SYNC : 0;
return flags;
}
@VisibleForTesting(otherwise = NONE)
public Allocator getAllocator() {
return allocator;
}
/**
* Peeks a sample from the front of the given {@link SampleQueue}, discarding the downstream
* {@link Format} first, if necessary.
*
* @param decoderInputBuffer The buffer to populate.
* @param omitSampleData Whether to omit the sample's data.
* @throws IllegalStateException If a sample is not peeked as a result of calling this method.
*/
private void peekNextSelectedTrackSample(
DecoderInputBuffer decoderInputBuffer, boolean omitSampleData) {
MediaExtractorTrack trackOfSample =
tracks.get(checkNotNull(trackIndicesPerSampleInQueuedOrder.peekFirst()));
SampleQueue sampleQueue = trackOfSample.sampleQueue;
@ReadFlags int readFlags = FLAG_PEEK | (omitSampleData ? FLAG_OMIT_SAMPLE_DATA : 0);
@ReadDataResult
int result =
sampleQueue.read(formatHolder, decoderInputBuffer, readFlags, /* loadingFinished= */ false);
if (result == C.RESULT_FORMAT_READ) {
// We've consumed a downstream format. Now consume the actual sample.
result =
sampleQueue.read(
formatHolder, decoderInputBuffer, readFlags, /* loadingFinished= */ false);
}
formatHolder.clear();
// Additional logging is added to debug b/241321832.
if (result != C.RESULT_BUFFER_READ) {
// This method should only be called when there is a sample available for reading.
throw new IllegalStateException(
Util.formatInvariant(
"Sample read result: %s\n"
+ "Track sample: %s\n"
+ "TrackIndicesPerSampleInQueuedOrder: %s\n"
+ "Tracks added: %s\n",
result, trackOfSample, trackIndicesPerSampleInQueuedOrder, tracks));
}
}
/**
* Returns the extractor to use for extracting samples from the given {@code input}.
*
* @throws IOException If an error occurs while extracting the media.
* @throws UnrecognizedInputFormatException If none of the available extractors successfully
* sniffs the input.
*/
private Extractor selectExtractor(ExtractorInput input) throws IOException {
Extractor[] extractors = extractorsFactory.createExtractors();
Extractor result = null;
for (Extractor extractor : extractors) {
try {
if (extractor.sniff(input)) {
result = extractor;
break;
}
} catch (EOFException e) {
// We reached the end of input without recognizing the input format. Do nothing to let the
// next extractor sniff the content.
} finally {
input.resetPeekPosition();
}
}
if (result == null) {
throw new UnrecognizedInputFormatException(
"None of the available extractors ("
+ Joiner.on(", ")
.join(
Lists.transform(
ImmutableList.copyOf(extractors),
extractor ->
extractor.getUnderlyingImplementation().getClass().getSimpleName()))
+ ") could read the stream.",
checkNotNull(checkNotNull(currentDataSource).getUri()),
ImmutableList.of());
}
return result;
}
/**
* Advances extraction until there is a queued sample from a selected track, or the end of the
* input is found.
*
* <p>Handles I/O errors (for example, network connection loss) and parsing errors (for example, a
* truncated file) in the same way as {@link MediaExtractor}, treating them as the end of input.
*
* @return Whether a sample from a selected track is available.
*/
@EnsuresNonNullIf(expression = "trackIndicesPerSampleInQueuedOrder.peekFirst()", result = true)
private boolean advanceToSampleOrEndOfInput() {
try {
maybeResolvePendingSeek();
} catch (IOException e) {
Log.w(TAG, "Treating exception as the end of input.", e);
return false;
}
boolean seenEndOfInput = false;
while (true) {
if (!trackIndicesPerSampleInQueuedOrder.isEmpty()) {
// By default, tracks are unselected.
if (selectedTrackIndices.contains(trackIndicesPerSampleInQueuedOrder.peekFirst())) {
return true;
} else {
// There is a queued sample, but its track is unselected. We skip the sample.
skipOneSample();
}
} else if (!seenEndOfInput) {
// There are no queued samples for the selected tracks, but we can feed more data to the
// extractor and see if more samples are produced.
try {
@ReadResult
int result =
checkNotNull(currentExtractor)
.read(checkNotNull(currentExtractorInput), positionHolder);
if (result == Extractor.RESULT_END_OF_INPUT) {
seenEndOfInput = true;
} else if (result == Extractor.RESULT_SEEK) {
this.currentExtractorInput = reopenCurrentDataSource(positionHolder.position);
}
} catch (Exception | OutOfMemoryError e) {
Log.w(TAG, "Treating exception as the end of input.", e);
seenEndOfInput = true;
}
} else {
// No queued samples for selected tracks, and we've parsed all the file. Nothing else to do.
return false;
}
}
}
private void skipOneSample() {
int trackIndex = trackIndicesPerSampleInQueuedOrder.removeFirst();
MediaExtractorTrack track = tracks.get(trackIndex);
if (!track.isCompatibilityTrack) {
// We also need to skip the sample data.
track.discardFrontSample();
}
}
private ExtractorInput reopenCurrentDataSource(long newPositionInStream) throws IOException {
DataSource currentDataSource = checkNotNull(this.currentDataSource);
Uri currentUri = checkNotNull(currentDataSource.getUri());
DataSourceUtil.closeQuietly(currentDataSource);
long length =
currentDataSource.open(
buildDataSpec(currentUri, offsetInCurrentFile + newPositionInStream));
if (length != C.LENGTH_UNSET) {
length += newPositionInStream;
}
return new DefaultExtractorInput(currentDataSource, newPositionInStream, length);
}
private void onSampleQueueFormatInitialized(
MediaExtractorSampleQueue mediaExtractorSampleQueue, Format newUpstreamFormat) {
upstreamFormatsCount++;
mediaExtractorSampleQueue.setMainTrackIndex(tracks.size());
tracks.add(
new MediaExtractorTrack(
mediaExtractorSampleQueue,
/* isCompatibilityTrack= */ false,
/* compatibilityTrackMimeType= */ null));
@Nullable
String compatibilityTrackMimeType =
MediaCodecUtil.getAlternativeCodecMimeType(newUpstreamFormat);
if (compatibilityTrackMimeType != null) {
mediaExtractorSampleQueue.setCompatibilityTrackIndex(tracks.size());
tracks.add(
new MediaExtractorTrack(
mediaExtractorSampleQueue,
/* isCompatibilityTrack= */ true,
compatibilityTrackMimeType));
}
}
private void maybeResolvePendingSeek() throws IOException {
if (this.pendingSeek == null) {
return; // Nothing to do.
}
SeekPoint pendingSeek = checkNotNull(this.pendingSeek);
checkNotNull(currentExtractor).seek(pendingSeek.position, pendingSeek.timeUs);
this.currentExtractorInput = reopenCurrentDataSource(pendingSeek.position);
this.pendingSeek = null;
}
/**
* Create a new {@link DataSpec} with the given data.
*
* <p>The created {@link DataSpec} disables caching if the content length cannot be resolved,
* since this is indicative of a progressive live stream.
*/
private static DataSpec buildDataSpec(Uri uri, long position) {
return new DataSpec.Builder()
.setUri(uri)
.setPosition(position)
.setFlags(
DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN | DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION)
.build();
}
private final class ExtractorOutputImpl implements ExtractorOutput {
@Override
public TrackOutput track(int id, int type) {
MediaExtractorSampleQueue sampleQueue = sampleQueues.get(id);
if (sampleQueue != null) {
// This track has already been declared. We return the sample queue that corresponds to this
// id.
return sampleQueue;
}
if (tracksEnded) {
// The id is new and the extractor has ended the tracks. Discard.
return new DummyTrackOutput();
}
sampleQueue = new MediaExtractorSampleQueue(allocator, id);
sampleQueues.put(id, sampleQueue);
return sampleQueue;
}
@Override
public void endTracks() {
tracksEnded = true;
}
@Override
public void seekMap(SeekMap seekMap) {
MediaExtractorCompat.this.seekMap = seekMap;
}
}
private static final class MediaExtractorTrack {
public final MediaExtractorSampleQueue sampleQueue;
public final boolean isCompatibilityTrack;
@Nullable public final String compatibilityTrackMimeType;
private MediaExtractorTrack(
MediaExtractorSampleQueue sampleQueue,
boolean isCompatibilityTrack,
@Nullable String compatibilityTrackMimeType) {
this.sampleQueue = sampleQueue;
this.isCompatibilityTrack = isCompatibilityTrack;
this.compatibilityTrackMimeType = compatibilityTrackMimeType;
}
public MediaFormat createDownstreamMediaFormat(
FormatHolder scratchFormatHolder, DecoderInputBuffer scratchNoDataDecoderInputBuffer) {
scratchFormatHolder.clear();
sampleQueue.read(
scratchFormatHolder,
scratchNoDataDecoderInputBuffer,
FLAG_REQUIRE_FORMAT,
/* loadingFinished= */ false);
Format result = checkNotNull(scratchFormatHolder.format);
MediaFormat mediaFormatResult = MediaFormatUtil.createMediaFormatFromFormat(result);
scratchFormatHolder.clear();
if (compatibilityTrackMimeType != null) {
mediaFormatResult.setString(MediaFormat.KEY_CODECS_STRING, null);
mediaFormatResult.setString(MediaFormat.KEY_MIME, compatibilityTrackMimeType);
}
return mediaFormatResult;
}
public void discardFrontSample() {
sampleQueue.skip(/* count= */ 1);
sampleQueue.discardToRead();
}
public int getIdOfBackingTrack() {
return sampleQueue.trackId;
}
@Override
public String toString() {
return String.format(
"MediaExtractorSampleQueue: %s, isCompatibilityTrack: %s, compatibilityTrackMimeType: %s",
sampleQueue, isCompatibilityTrack, compatibilityTrackMimeType);
}
}
private final class MediaExtractorSampleQueue extends SampleQueue {
public final int trackId;
private int mainTrackIndex;
private int compatibilityTrackIndex;
public MediaExtractorSampleQueue(Allocator allocator, int trackId) {
// We do not need the sample queue to acquire keys for encrypted samples, so we pass null
// values for DRM-related arguments.
super(allocator, /* drmSessionManager= */ null, /* drmEventDispatcher= */ null);
this.trackId = trackId;
mainTrackIndex = C.INDEX_UNSET;
compatibilityTrackIndex = C.INDEX_UNSET;
}
public void setMainTrackIndex(int mainTrackIndex) {
this.mainTrackIndex = mainTrackIndex;
}
public void setCompatibilityTrackIndex(int compatibilityTrackIndex) {
this.compatibilityTrackIndex = compatibilityTrackIndex;
}
// SampleQueue implementation.
@Override
public Format getAdjustedUpstreamFormat(Format format) {
if (getUpstreamFormat() == null) {
onSampleQueueFormatInitialized(this, format);
}
return super.getAdjustedUpstreamFormat(format);
}
@Override
public void sampleMetadata(
long timeUs, int flags, int size, int offset, @Nullable CryptoData cryptoData) {
// Disable BUFFER_FLAG_LAST_SAMPLE to prevent the sample queue from ignoring
// FLAG_REQUIRE_FORMAT. See b/191518632.
flags &= ~C.BUFFER_FLAG_LAST_SAMPLE;
if (compatibilityTrackIndex != C.INDEX_UNSET) {
trackIndicesPerSampleInQueuedOrder.addLast(compatibilityTrackIndex);
}
Assertions.checkState(mainTrackIndex != C.INDEX_UNSET);
trackIndicesPerSampleInQueuedOrder.addLast(mainTrackIndex);
super.sampleMetadata(timeUs, flags, size, offset, cryptoData);
}
@Override
public String toString() {
return String.format(
"trackId: %s, mainTrackIndex: %s, compatibilityTrackIndex: %s",
trackId, mainTrackIndex, compatibilityTrackIndex);
}
}
}

View File

@ -0,0 +1,602 @@
/*
* 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;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.media.MediaFormat;
import android.net.Uri;
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.datasource.DefaultDataSource;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.SeekMap.SeekPoints;
import androidx.media3.extractor.SeekPoint;
import androidx.media3.extractor.TrackOutput;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Tests for {@link MediaExtractorCompat}. */
@RunWith(AndroidJUnit4.class)
public class MediaExtractorCompatTest {
/**
* Placeholder data URI which saves us from mocking the data source which MediaExtractorCompat
* uses.
*
* <p>Note: The created data source will be opened, but no data will be read from it, so the
* contents are irrelevant.
*/
private static final Uri PLACEHOLDER_URI = Uri.parse("data:,0123456789");
private static final Format PLACEHOLDER_FORMAT_AUDIO =
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build();
private static final Format PLACEHOLDER_FORMAT_VIDEO =
new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build();
private FakeExtractor fakeExtractor;
private SeekMap fakeSeekMap;
private MediaExtractorCompat mediaExtractorCompat;
private ExtractorOutput extractorOutput;
@Before
public void setUp() {
fakeExtractor = new FakeExtractor();
fakeSeekMap = new FakeSeekMap();
ExtractorsFactory factory = () -> new Extractor[] {fakeExtractor};
mediaExtractorCompat =
new MediaExtractorCompat(
factory, new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext()));
}
@Test
public void setDataSource_forEmptyContainerFile_producesZeroTracks() throws IOException {
fakeExtractor.addReadAction(
(input, seekPosition) -> {
extractorOutput.endTracks();
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(0);
}
@Test
public void setDataSource_doesNotPerformMoreReadsThanNecessary() throws IOException {
TrackOutput[] trackOutputs = new TrackOutput[2];
fakeExtractor.addReadAction(
(input, seekPosition) -> {
trackOutputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
trackOutputs[1] = extractorOutput.track(/* id= */ 1, C.TRACK_TYPE_VIDEO);
extractorOutput.endTracks();
// Should be ignored. Tracks have ended and the id doesn't exist.
extractorOutput.track(/* id= */ 2, C.TRACK_TYPE_TEXT);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
trackOutputs[0].format(PLACEHOLDER_FORMAT_AUDIO);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
// After this read call, the extractor should have enough to finish preparation:
// formats, seek map, and the tracks have ended.
trackOutputs[1].format(PLACEHOLDER_FORMAT_VIDEO);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(MediaExtractorCompatTest::assertionFailureReadAction);
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2);
assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME))
.isEqualTo(PLACEHOLDER_FORMAT_AUDIO.sampleMimeType);
assertThat(mediaExtractorCompat.getTrackFormat(1).getString(MediaFormat.KEY_MIME))
.isEqualTo(PLACEHOLDER_FORMAT_VIDEO.sampleMimeType);
}
@Test
public void setDataSource_withTrackIdReuse_reusesTrackOutput() throws IOException {
fakeExtractor.addReadAction(
(input, seekPosition) -> {
TrackOutput trackOutput1 = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO);
TrackOutput trackOutput2 = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO);
assertThat(trackOutput1).isSameInstanceAs(trackOutput2);
trackOutput1.format(PLACEHOLDER_FORMAT_AUDIO);
extractorOutput.endTracks();
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
}
@Test
public void readSampleData_forInterleavedInputSamples_producesExpectedSamples()
throws IOException {
TrackOutput[] outputs = new TrackOutput[2];
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
outputs[0].format(PLACEHOLDER_FORMAT_VIDEO);
outputs[1] = extractorOutput.track(/* id= */ 1, C.TRACK_TYPE_AUDIO);
outputs[1].format(PLACEHOLDER_FORMAT_AUDIO);
extractorOutput.endTracks();
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputSampleData(outputs[0], /* sampleData...= */ (byte) 1, (byte) 2, (byte) 3);
outputSampleData(outputs[1], /* sampleData...= */ (byte) 4, (byte) 5, (byte) 6);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputSample(outputs[0], /* timeUs= */ 4, /* size= */ 1, /* offset= */ 2);
outputSample(outputs[1], /* timeUs= */ 3, /* size= */ 1, /* offset= */ 2);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputSample(outputs[0], /* timeUs= */ 2, /* size= */ 2, /* offset= */ 0);
outputSample(outputs[1], /* timeUs= */ 1, /* size= */ 2, /* offset= */ 0);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(MediaExtractorCompatTest::assertionFailureReadAction);
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2);
mediaExtractorCompat.selectTrack(0);
mediaExtractorCompat.selectTrack(1);
assertReadSample(/* trackIndex= */ 0, /* timeUs= */ 4, /* sampleData...= */ (byte) 1);
mediaExtractorCompat.advance();
assertReadSample(/* trackIndex= */ 1, /* timeUs= */ 3, /* sampleData...= */ (byte) 4);
mediaExtractorCompat.advance();
assertReadSample(/* trackIndex= */ 0, /* timeUs= */ 2, /* sampleData...= */ (byte) 2, (byte) 3);
mediaExtractorCompat.advance();
assertReadSample(/* trackIndex= */ 1, /* timeUs= */ 1, /* sampleData...= */ (byte) 5, (byte) 6);
}
@Test
public void getTrackFormat_atEndOfStreamWithFlagLastSample_producesATrackFormat()
throws IOException {
// This is a regression test for b/191518632.
TrackOutput[] outputs = new TrackOutput[1];
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
outputs[0].format(PLACEHOLDER_FORMAT_VIDEO);
extractorOutput.endTracks();
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputSampleData(outputs[0], /* sampleData...= */ (byte) 1, (byte) 2, (byte) 3);
outputs[0].sampleMetadata(
/* timeUs= */ 0,
/* flags= */ C.BUFFER_FLAG_KEY_FRAME | C.BUFFER_FLAG_LAST_SAMPLE,
/* size= */ 3,
/* offset= */ 0,
/* cryptoData= */ null);
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(1);
mediaExtractorCompat.selectTrack(0);
mediaExtractorCompat.advance();
// After skipping the only sample, there should be none left, and getSampleTime should return
// -1.
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME))
.isEqualTo(PLACEHOLDER_FORMAT_VIDEO.sampleMimeType);
}
@Test
public void setDataSource_withOutOfMemoryError_wrapsError() throws IOException {
fakeExtractor.addReadAction(
(input, seekPosition) -> {
throw new OutOfMemoryError();
});
ParserException exception =
assertThrows(
ParserException.class,
() -> mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0));
assertThat(exception).hasCauseThat().isInstanceOf(OutOfMemoryError.class);
}
@Test
public void getSampleTime_withOutOfMemoryError_producesEndOfInput() throws IOException {
// This boolean guarantees that this test remains useful. The throwing read action is being
// called as a result of an implementation detail (trying to parse a container file with no
// tracks) that could change in the future. Counting on this implementation detail simplifies
// the test.
AtomicBoolean outOfMemoryErrorWasThrown = new AtomicBoolean(false);
fakeExtractor.addReadAction(
(input, seekPosition) -> {
extractorOutput.endTracks();
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outOfMemoryErrorWasThrown.set(true);
throw new OutOfMemoryError();
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
assertThat(outOfMemoryErrorWasThrown.get()).isTrue();
}
@Test
public void setDataSource_withDolbyVision_generatesCompatibilityTrack() throws IOException {
TrackOutput[] outputs = new TrackOutput[1];
byte[] sampleData = new byte[] {(byte) 1, (byte) 2, (byte) 3};
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
extractorOutput.endTracks();
outputs[0].format(
new Format.Builder()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs("hev1.08.10")
.build());
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
outputSampleData(outputs[0], sampleData);
outputSample(outputs[0], /* timeUs= */ 7, /* size= */ 3, /* offset= */ 0);
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2);
assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME))
.isEqualTo(MimeTypes.VIDEO_DOLBY_VISION);
assertThat(mediaExtractorCompat.getTrackFormat(1).getString(MediaFormat.KEY_MIME))
.isEqualTo(MimeTypes.VIDEO_H265);
ByteBuffer scratchBuffer = ByteBuffer.allocate(3);
mediaExtractorCompat.selectTrack(0);
mediaExtractorCompat.selectTrack(1);
assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(1);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
assertThat(mediaExtractorCompat.readSampleData(scratchBuffer, /* offset= */ 0)).isEqualTo(3);
assertThat(scratchBuffer.array()).isEqualTo(sampleData);
assertThat(mediaExtractorCompat.advance()).isTrue();
assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(0);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
assertThat(mediaExtractorCompat.readSampleData(scratchBuffer, /* offset= */ 0)).isEqualTo(3);
assertThat(scratchBuffer.array()).isEqualTo(sampleData);
assertThat(mediaExtractorCompat.advance()).isFalse();
}
@Test
public void seekTo_resetsSampleQueues() throws IOException {
fakeExtractor.setSeekStrategy(fakeExtractor::rewindReadActions);
fakeExtractor.addReadAction(
(input, seekPosition) -> {
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
extractorOutput.endTracks();
output.format(PLACEHOLDER_FORMAT_VIDEO);
outputSampleData(output, /* sampleData...= */ (byte) 0);
outputSample(output, /* timeUs= */ 7, /* size= */ 1, /* offset= */ 0);
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
// Calling getSampleTime forces the extractor to populate the sample queues with the first
// sample. As a result, to pass this test, the tested implementation must clear the sample
// queues when seekTo is called.
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
mediaExtractorCompat.seekTo(/* timeUs= */ 0, MediaExtractorCompat.SEEK_TO_PREVIOUS_SYNC);
// Test the same sample (and only that sample) is read after the seek to the start.
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
assertThat(mediaExtractorCompat.advance()).isFalse();
}
@Test
public void readSampleData_usesOffsetArgumentInsteadOfBufferPosition() throws IOException {
byte[] sampleData =
new byte[] {
(byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, (byte) 7, (byte) 8, (byte) 9
};
fakeExtractor.addReadAction(
(input, seekPosition) -> {
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
extractorOutput.endTracks();
output.format(PLACEHOLDER_FORMAT_VIDEO);
outputSampleData(output, sampleData);
outputSample(output, /* timeUs= */ 1, /* size= */ 2, /* offset= */ 7);
outputSample(output, /* timeUs= */ 2, /* size= */ 3, /* offset= */ 4);
outputSample(output, /* timeUs= */ 3, /* size= */ 4, /* offset= */ 0);
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
ByteBuffer byteBuffer = ByteBuffer.allocate(9);
// Set the position to the limit to test that the position is ignored, like the platform media
// extractor implementation does.
byteBuffer.position(byteBuffer.limit());
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(1);
assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 0)).isEqualTo(2);
assertThat(byteBuffer.position()).isEqualTo(0);
assertThat(byteBuffer.limit()).isEqualTo(2);
mediaExtractorCompat.advance();
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(2);
assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 2)).isEqualTo(3);
assertThat(byteBuffer.position()).isEqualTo(2);
assertThat(byteBuffer.limit()).isEqualTo(5);
mediaExtractorCompat.advance();
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(3);
assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 5)).isEqualTo(4);
assertThat(byteBuffer.position()).isEqualTo(5);
assertThat(byteBuffer.limit()).isEqualTo(9);
assertThat(byteBuffer.array()).isEqualTo(sampleData);
assertThat(mediaExtractorCompat.advance()).isFalse();
}
@Test
public void advance_releasesMemoryOfSkippedSamples() throws IOException {
// This is a regression test for b/209801945.
Allocator allocator = mediaExtractorCompat.getAllocator();
int individualAllocationSize = allocator.getIndividualAllocationLength();
fakeExtractor.addReadAction(
(input, seekPosition) -> {
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
extractorOutput.endTracks();
output.format(PLACEHOLDER_FORMAT_VIDEO);
outputSampleData(output, new byte[individualAllocationSize]);
outputSample(
output, /* timeUs= */ 1, /* size= */ individualAllocationSize, /* offset= */ 0);
return Extractor.RESULT_CONTINUE;
});
fakeExtractor.addReadAction(
(input, seekPosition) -> {
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
outputSampleData(output, new byte[individualAllocationSize * 2]);
outputSample(
output,
/* timeUs= */ 2,
/* size= */ individualAllocationSize,
/* offset= */ individualAllocationSize);
outputSample(
output, /* timeUs= */ 3, /* size= */ individualAllocationSize, /* offset= */ 0);
return Extractor.RESULT_END_OF_INPUT;
});
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(0);
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize);
mediaExtractorCompat.advance();
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize * 2);
mediaExtractorCompat.advance();
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize);
mediaExtractorCompat.advance();
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(0);
}
// Test for b/223910395.
@Test
public void seek_withUninterleavedFile_seeksToTheRightPosition() throws IOException {
// We don't use the global mediaExtractorCompat because we want to use a real extractor in this
// case, which is the Mp4 extractor.
MediaExtractorCompat mediaExtractorCompat =
new MediaExtractorCompat(ApplicationProvider.getApplicationContext());
// The asset is an uninterleaved mp4 file.
mediaExtractorCompat.setDataSource(
Uri.parse("asset:///media/mp4/mv_with_2_top_shots.mp4"), /* offset= */ 0);
mediaExtractorCompat.selectTrack(1);
mediaExtractorCompat.seekTo(1773911, MediaExtractorCompat.SEEK_TO_PREVIOUS_SYNC);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(1773911);
}
// Test for b/233756471.
@Test
public void seek_withException_producesEndOfInput() throws IOException {
Function<Long, SeekPoints> seekPointsFunction =
(timesUs) -> {
// For the mid seek point we use an invalid position which will cause an IOException. We
// expect that exception to be treated as the end of input.
SeekPoint midSeekPoint = new SeekPoint(/* timeUs= */ 14, /* position= */ 1000);
return timesUs < 14
? new SeekPoints(SeekPoint.START, midSeekPoint)
: new SeekPoints(midSeekPoint);
};
fakeExtractor.setSeekStrategy(fakeExtractor::rewindReadActions);
fakeExtractor.addReadAction(
(input, seekPosition) -> {
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
extractorOutput.endTracks();
output.format(PLACEHOLDER_FORMAT_VIDEO);
extractorOutput.seekMap(new FakeSeekMap(/* durationUs= */ 28, seekPointsFunction));
outputSampleData(output, /* sampleData...= */ (byte) 0, (byte) 1, (byte) 2);
outputSample(output, /* timeUs= */ 7, /* size= */ 1, /* offset= */ 2);
outputSample(output, /* timeUs= */ 14, /* size= */ 1, /* offset= */ 1);
outputSample(output, /* timeUs= */ 21, /* size= */ 1, /* offset= */ 0);
return Extractor.RESULT_END_OF_INPUT;
});
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
mediaExtractorCompat.advance();
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(14);
mediaExtractorCompat.advance();
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(21);
mediaExtractorCompat.advance();
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
// This seek will cause the target position to be invalid, causing an IOException which should
// be treated as the end of input.
mediaExtractorCompat.seekTo(/* timeUs= */ 14, MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
// This seek should go to position 0, which should be handled correctly again.
mediaExtractorCompat.seekTo(/* timeUs= */ 0, MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
}
// Internal methods.
private void assertReadSample(int trackIndex, long timeUs, byte... sampleData) {
assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(trackIndex);
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(timeUs);
ByteBuffer buffer = ByteBuffer.allocate(100);
assertThat(mediaExtractorCompat.readSampleData(buffer, /* offset= */ 0))
.isEqualTo(sampleData.length);
for (int i = 0; i < buffer.remaining(); i++) {
assertThat(buffer.get()).isEqualTo(sampleData[i]);
}
}
private static void outputSampleData(TrackOutput trackOutput, byte... sampleData) {
trackOutput.sampleData(new ParsableByteArray(sampleData), sampleData.length);
}
private static void outputSample(TrackOutput trackOutput, long timeUs, int size, int offset) {
trackOutput.sampleMetadata(
timeUs, C.BUFFER_FLAG_KEY_FRAME, size, offset, /* cryptoData= */ null);
}
/**
* Read action to verify that {@link MediaExtractorCompat} does not read more data than expected.
*/
private static int assertionFailureReadAction(ExtractorInput input, PositionHolder holder) {
throw new AssertionError("ExoPlayerBackedMediaExtractorProxy read more data than needed.");
}
// Internal classes.
private static class FakeSeekMap implements SeekMap {
private final long durationUs;
private final Function<Long, SeekPoints> seekPointsFunction;
public FakeSeekMap() {
this(C.TIME_UNSET, (timeUs) -> new SeekPoints(SeekPoint.START));
}
public FakeSeekMap(long durationUs, Function<Long, SeekPoints> seekPointsFunction) {
this.durationUs = durationUs;
this.seekPointsFunction = seekPointsFunction;
}
@Override
public boolean isSeekable() {
return true;
}
@Override
public long getDurationUs() {
return durationUs;
}
@Override
public SeekPoints getSeekPoints(long timeUs) {
return seekPointsFunction.apply(timeUs);
}
}
private class FakeExtractor implements Extractor {
private final ArrayList<BiFunction<ExtractorInput, PositionHolder, Integer>> readActions;
private BiConsumer<Long, Long> seekStrategy;
private int nextReadActionIndex;
public FakeExtractor() {
readActions = new ArrayList<>();
nextReadActionIndex = 0;
seekStrategy = (arg1, arg2) -> {};
}
public void addReadAction(BiFunction<ExtractorInput, PositionHolder, Integer> readAction) {
readActions.add(readAction);
}
public void setSeekStrategy(BiConsumer<Long, Long> seekStrategy) {
this.seekStrategy = seekStrategy;
}
public void rewindReadActions(long position, long timeUs) {
nextReadActionIndex = 0;
}
// Extractor implementation.
@Override
public boolean sniff(ExtractorInput input) {
return true;
}
@Override
public void init(ExtractorOutput extractorOutput) {
MediaExtractorCompatTest.this.extractorOutput = extractorOutput;
extractorOutput.seekMap(fakeSeekMap);
}
@Override
public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException {
if (nextReadActionIndex >= readActions.size()) {
return C.RESULT_END_OF_INPUT;
} else {
return readActions.get(nextReadActionIndex++).apply(input, seekPosition);
}
}
@Override
public void seek(long position, long timeUs) {
seekStrategy.accept(position, timeUs);
}
@Override
public void release() {}
}
}