From de0e08397e7b1195641065abf96c78107a3c1dc4 Mon Sep 17 00:00:00 2001 From: rohks Date: Tue, 12 Nov 2024 05:31:18 -0800 Subject: [PATCH] Add caching status APIs to `MediaExtractorCompat` Implemented `getCachedDuration()` to provide an estimate of cached data in memory and `hasCacheReachedEndOfStream()` to indicate if the cache has reached the end of the stream. Note: The Javadoc for the newly added methods closely follows that of the platform `MediaExtractor`. While the current implementation always uses a cache and therefore never returns `-1` from `getCachedDuration`, this leaves room for future changes should caching behavior or conditions evolve. PiperOrigin-RevId: 695694460 --- .../exoplayer/MediaExtractorCompatTest.java | 209 ++++++++++++++++++ .../exoplayer/MediaExtractorCompat.java | 50 +++++ 2 files changed, 259 insertions(+) diff --git a/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java b/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java index d9e6b3dc12..1493e666f4 100644 --- a/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java +++ b/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java @@ -793,6 +793,215 @@ public class MediaExtractorCompatTest { assertThat(mediaExtractorCompat.getDrmInitData()).isEqualTo(firstDrmInitData); } + @Test + public void + getCachedDurationAndHasCacheReachedEndOfStream_withSingleTrackAndNoneSelected_returnsExpectedValues() + throws IOException { + 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); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 0, /* size= */ 3, /* offset= */ 0); + return Extractor.RESULT_CONTINUE; + }); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + + // Sample is queued but discarded since no track is selected. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(0); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isTrue(); + } + + @Test + public void + getCachedDurationAndHasCacheReachedEndOfStream_withSingleTrackAndSelected_returnsExpectedValues() + throws IOException { + 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); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 0, /* size= */ 1, /* offset= */ 2); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 100_000, /* size= */ 1, /* offset= */ 1); + outputSample(outputs[0], /* timeUs= */ 200_000, /* size= */ 1, /* offset= */ 0); + return Extractor.RESULT_CONTINUE; + }); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(0); + + // First sample queued but not read; returns default duration for last sample. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(10_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Remaining two samples queued, first sample read. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(210_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Second sample read. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(110_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Final sample read; no remaining samples, so cached duration is zero and has reached end of + // stream. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(0); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isTrue(); + } + + @Test + public void + getCachedDurationAndHasCacheReachedEndOfStream_withMultipleTracksAndOneSelected_returnsExpectedValues() + 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); + outputSampleData(outputs[1], /* sampleData...= */ (byte) 4, (byte) 5, (byte) 6); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 0, /* size= */ 1, /* offset= */ 2); + outputSample(outputs[1], /* timeUs= */ 0, /* size= */ 1, /* offset= */ 2); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 100_000, /* size= */ 1, /* offset= */ 1); + outputSample(outputs[1], /* timeUs= */ 200_000, /* size= */ 1, /* offset= */ 1); + outputSample(outputs[1], /* timeUs= */ 300_000, /* size= */ 1, /* offset= */ 0); + return Extractor.RESULT_CONTINUE; + }); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(0); + + // First two samples queued but not read; returns default duration for last sample. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(10_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // All samples queued, first sample read. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(310_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Second sample read; remaining samples are from an unselected track and are discarded. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(0); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isTrue(); + } + + @Test + public void + getCachedDurationAndHasCacheReachedEndOfStream_withMultipleTracksAndAllSelected_returnsExpectedValues() + 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); + outputSampleData(outputs[1], /* sampleData...= */ (byte) 4, (byte) 5, (byte) 6); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 0, /* size= */ 1, /* offset= */ 2); + outputSample(outputs[1], /* timeUs= */ 0, /* size= */ 1, /* offset= */ 2); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 100_000, /* size= */ 1, /* offset= */ 1); + outputSample(outputs[1], /* timeUs= */ 200_000, /* size= */ 1, /* offset= */ 1); + outputSample(outputs[1], /* timeUs= */ 300_000, /* size= */ 1, /* offset= */ 0); + return Extractor.RESULT_CONTINUE; + }); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(0); + mediaExtractorCompat.selectTrack(1); + + // First two samples queued but not read; returns default duration for last sample. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(10_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + mediaExtractorCompat.advance(); + + // All samples queued, first and second sample read. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(310_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Third sample read. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(210_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Fourth sample read. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(110_000); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isFalse(); + + mediaExtractorCompat.advance(); + + // Final sample read; no remaining samples, so cached duration is zero and has reached end of + // stream. + assertThat(mediaExtractorCompat.getCachedDuration()).isEqualTo(0); + assertThat(mediaExtractorCompat.hasCacheReachedEndOfStream()).isTrue(); + } + // Internal methods. private void assertReadSample(int trackIndex, long timeUs, int size, byte... sampleData) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java index 20782c998d..06ceaabdec 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java @@ -20,6 +20,7 @@ import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.exoplayer.source.SampleStream.FLAG_PEEK; import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT; +import static java.lang.Math.max; import android.content.ContentResolver; import android.content.Context; @@ -115,6 +116,14 @@ public final class MediaExtractorCompat { /** See {@link MediaExtractor#SEEK_TO_CLOSEST_SYNC}. */ public static final int SEEK_TO_CLOSEST_SYNC = MediaExtractor.SEEK_TO_CLOSEST_SYNC; + /** + * A default duration added to the largest queued sample timestamp to provide a more realistic + * estimate of the cached duration. Since the duration of the last sample is unknown, this value + * prevents the duration of the last sample from being assumed as zero, which would otherwise make + * the estimated duration appear shorter than it actually is. + */ + private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10_000; + private static final String TAG = "MediaExtractorCompat"; private final ExtractorsFactory extractorsFactory; @@ -607,6 +616,47 @@ public final class MediaExtractorCompat { return null; } + /** + * Returns an estimate of how much data is presently cached in memory, expressed in microseconds, + * or -1 if this information is unavailable or not applicable (i.e., no cache exists). + */ + public long getCachedDuration() { + if (!advanceToSampleOrEndOfInput()) { + return 0; + } + + long largestReadTimestampUs = Long.MIN_VALUE; + long largestQueuedTimestampUs = Long.MIN_VALUE; + for (int i = 0; i < tracks.size(); i++) { + MediaExtractorSampleQueue mediaExtractorSampleQueue = tracks.get(i).sampleQueue; + + largestReadTimestampUs = + max(largestReadTimestampUs, mediaExtractorSampleQueue.getLargestReadTimestampUs()); + largestQueuedTimestampUs = + max(largestQueuedTimestampUs, mediaExtractorSampleQueue.getLargestQueuedTimestampUs()); + } + + checkState(largestQueuedTimestampUs != Long.MIN_VALUE); + if (largestReadTimestampUs == largestQueuedTimestampUs) { + return 0; + } + + if (largestReadTimestampUs == Long.MIN_VALUE) { + largestReadTimestampUs = 0; + } + return largestQueuedTimestampUs - largestReadTimestampUs + DEFAULT_LAST_SAMPLE_DURATION_US; + } + + /** + * Returns {@code true} if data is being cached and the cache has reached the end of the data + * stream. This indicates that no additional data is currently available for caching, although a + * future seek may restart data fetching. This method only returns a meaningful result if {@link + * #getCachedDuration} indicates the presence of a cache (i.e., does not return -1). + */ + public boolean hasCacheReachedEndOfStream() { + return getCachedDuration() == 0; + } + @VisibleForTesting(otherwise = NONE) public Allocator getAllocator() { return allocator;