From 57d516016189e68ef80063a61e08fcd306f8b1b8 Mon Sep 17 00:00:00 2001 From: Steve Mayhew Date: Fri, 16 Aug 2019 15:56:32 -0700 Subject: [PATCH 0001/1052] add support SequenceableLoader.reevaluateBuffer() for HLS DASH implements this feature, extend the feature for HLS as well. First change just drops video samples. For demuxed audio the audio samples will continue to play out to match the dropped video, so need to keep indexes in all the sample queues related to a chunk and discard them all. --- .../exoplayer2/source/hls/HlsChunkSource.java | 7 ++ .../exoplayer2/source/hls/HlsMediaChunk.java | 17 +++++ .../source/hls/HlsSampleStreamWrapper.java | 74 ++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index 1a77715e71..a7caf2d7aa 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -451,6 +451,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return chunkIterators; } + public int getPreferredQueueSize(long playbackPositionUs, List queue) { + if (fatalError != null || trackSelection.length() < 2) { + return queue.size(); + } + return trackSelection.evaluateQueueSize(playbackPositionUs, queue); + } + // Private methods. /** diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 8845228900..562d820c49 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.id3.PrivFrame; +import com.google.android.exoplayer2.source.SampleQueue; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; import com.google.android.exoplayer2.upstream.DataSource; @@ -222,6 +223,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private volatile boolean loadCanceled; private boolean loadCompleted; + /** + * Index of first sample written to the SampleQueue for the primary track from + * this segment. + */ + private int firstSampleIndex = C.INDEX_UNSET; + private HlsMediaChunk( HlsExtractorFactory extractorFactory, DataSource mediaDataSource, @@ -291,6 +298,15 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return loadCompleted; } + /** + * Return the index of the first sample from the primary sample stream for this media chunk + * + * @return sample index {@link SampleQueue#getWriteIndex()} + */ + public int getFirstPrimarySampleIndex() { + return firstSampleIndex; + } + // Loadable implementation @Override @@ -308,6 +324,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; initDataLoadRequired = false; output.init(uid, shouldSpliceIn, /* reusingExtractor= */ true); } + firstSampleIndex = output.getPrimaryTrackWritePosition(); maybeLoadInitData(); if (!loadCanceled) { if (!hasGapTag) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index d99bb817c1..4f4fe9e196 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -696,9 +696,65 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void reevaluateBuffer(long positionUs) { - // Do nothing. + if (loader.isLoading() || isPendingReset()) { + return; + } + + int currentQueueSize = mediaChunks.size(); + int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (currentQueueSize > preferredQueueSize) { + Log.d(TAG, "reevaluateBuffer() - position: " + positionUs + " preferredQueueSize: " + + preferredQueueSize + " current size: "+ currentQueueSize); + + long firstRemovedStartTimeUs = discardMediaChunks(preferredQueueSize); + long endTimeUs = getLastMediaChunk().endTimeUs; + eventDispatcher.upstreamDiscarded(primarySampleQueueType, firstRemovedStartTimeUs, endTimeUs); + } } + + /** + * Discards HlsMediaChunks, after currently playing chunk {@see #haveReadFromMediaChunk}, that have + * not yet started to play to allow (hopefully) higher quality chunks to replace them + * + * @param preferredQueueSize - desired media chunk queue size (always < mediaChunks.size()) + * @return endTimeUs of first chunk removed + */ + private long discardMediaChunks(int preferredQueueSize) { + Log.d(TAG, "discardChunksToIndex() - preferredQueueSize " + preferredQueueSize + + " currentSize " + mediaChunks.size() + + " write: "+sampleQueues[primarySampleQueueIndex].getWriteIndex() + + " read: "+sampleQueues[primarySampleQueueIndex].getReadIndex() + ); + for (int i=0; i 0) { + firstRemovedChunkIndex++; + preferredQueueSize--; + } + + HlsMediaChunk firstRemovedChunk = mediaChunks.get(firstRemovedChunkIndex); + Util.removeRange(mediaChunks, firstRemovedChunkIndex, mediaChunks.size() - 1); + Log.d(TAG, "discardChunksToIndex() - discard from: " + firstRemovedChunk.getFirstPrimarySampleIndex()); + + sampleQueues[primarySampleQueueIndex].discardUpstreamSamples(firstRemovedChunk.getFirstPrimarySampleIndex()); + + return firstRemovedChunk.endTimeUs; + } + + + /** Returns whether samples have been read from primary sample queue of the indicated chunk */ + private boolean haveReadFromMediaChunk(int mediaChunkIndex) { + HlsMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); + return sampleQueues[primarySampleQueueIndex].getReadIndex() > mediaChunk.getFirstPrimarySampleIndex(); + } // Loader.Callback implementation. @Override @@ -959,6 +1015,22 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } + /** + * Get the SampleQueue write position index. Used to associate group of + * samples with a MediaChunk. + * + * @return write position {@link SampleQueue#getWriteIndex()}, or 0 (safe bet) if sample queues not created + */ + int getPrimaryTrackWritePosition() { + int indexValue = 0; + + if (primaryTrackGroupIndex != C.INDEX_UNSET && prepared) { + int sampleQueueIndex = trackGroupToSampleQueueIndex[primaryTrackGroupIndex]; + indexValue = sampleQueues[sampleQueueIndex].getWriteIndex(); + } + return indexValue; + } + // Internal methods. private void updateSampleStreams(@NullableType SampleStream[] streams) { From a8755b5c253984420a03cd6171377dcfaacdc219 Mon Sep 17 00:00:00 2001 From: Steve Mayhew Date: Wed, 21 Aug 2019 15:38:27 -0700 Subject: [PATCH 0002/1052] Update to save all sample queue write indexes Finailzed logic to update the `SampleQueue` write positions (first index) to push these into `HlsMediaChunk` when the track is initially created (from the Extractor output) and as each new chunk is queued to load (`init()` callback). Add lots of debuging prints that can come out for the final merge. Code is very close to a clone of `ChunkSampleStream`. --- .../exoplayer2/source/hls/HlsMediaChunk.java | 18 ++- .../source/hls/HlsSampleStreamWrapper.java | 153 ++++++++++++------ 2 files changed, 119 insertions(+), 52 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 562d820c49..aeccc6b8db 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -40,6 +40,7 @@ import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; import java.math.BigInteger; +import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; @@ -224,10 +225,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private boolean loadCompleted; /** - * Index of first sample written to the SampleQueue for the primary track from - * this segment. + * Index of first sample written for each SampleQueue created from the extraction of this + * media chunk */ - private int firstSampleIndex = C.INDEX_UNSET; + private int [] firstSampleIndexes = new int[0]; private HlsMediaChunk( HlsExtractorFactory extractorFactory, @@ -303,8 +304,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * * @return sample index {@link SampleQueue#getWriteIndex()} */ - public int getFirstPrimarySampleIndex() { - return firstSampleIndex; + int [] getFirstSampleIndexes() { + return firstSampleIndexes; + } + + void setFirstSampleIndex(int streamIndex, int firstSampleIndex) { + firstSampleIndexes = Arrays.copyOf(firstSampleIndexes, streamIndex + 1); + firstSampleIndexes[streamIndex] = firstSampleIndex; } // Loadable implementation @@ -324,7 +330,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; initDataLoadRequired = false; output.init(uid, shouldSpliceIn, /* reusingExtractor= */ true); } - firstSampleIndex = output.getPrimaryTrackWritePosition(); + firstSampleIndexes = output.getTrackWritePositions(); maybeLoadInitData(); if (!loadCanceled) { if (!hasGapTag) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 4f4fe9e196..cb71cd5b36 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -62,6 +62,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.ListIterator; import java.util.Map; import java.util.Set; import org.checkerframework.checker.nullness.compatqual.NullableType; @@ -702,58 +703,86 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int currentQueueSize = mediaChunks.size(); int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); - if (currentQueueSize > preferredQueueSize) { - Log.d(TAG, "reevaluateBuffer() - position: " + positionUs + " preferredQueueSize: " - + preferredQueueSize + " current size: "+ currentQueueSize); - - long firstRemovedStartTimeUs = discardMediaChunks(preferredQueueSize); - long endTimeUs = getLastMediaChunk().endTimeUs; - eventDispatcher.upstreamDiscarded(primarySampleQueueType, firstRemovedStartTimeUs, endTimeUs); + if (currentQueueSize <= preferredQueueSize) { + return; } + + int newQueueSize = currentQueueSize; + for (int i = preferredQueueSize; i < currentQueueSize; i++) { + if (!haveReadFromMediaChunk(i)) { + newQueueSize = i; + break; + } + } + if (newQueueSize == currentQueueSize) { + return; + } + + Log.d(TAG, "Discarding MediaChunks - trackType: " + trackType + + " chunk count: " + currentQueueSize + + " target chunk count: " + newQueueSize); + + dumpCurrentChunkList(); + + long endTimeUs = getLastMediaChunk().endTimeUs; + HlsMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + loadingFinished = false; + + dumpCurrentChunkList(); + eventDispatcher.upstreamDiscarded(primarySampleQueueType, firstRemovedChunk.startTimeUs, endTimeUs); + } + + /** + * Discard upstream media chunks from {@code chunkIndex} and corresponding samples from sample + * queues. + * + * @param chunkIndex The index of the first chunk to discard. + * @return The chunk at given index. + */ + private HlsMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { + HlsMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); + Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); + + + int [] firstSamples = firstRemovedChunk.getFirstSampleIndexes(); + for (int i=0; i < sampleQueues.length; i++) { + Log.d(TAG, "discardUpstreamSamples() - stream: " + " from index: " + firstSamples[i]); + + sampleQueues[i].discardUpstreamSamples(firstSamples[i]); + } + + dumpCurrentChunkList(); + + return firstRemovedChunk; } - /** - * Discards HlsMediaChunks, after currently playing chunk {@see #haveReadFromMediaChunk}, that have - * not yet started to play to allow (hopefully) higher quality chunks to replace them - * - * @param preferredQueueSize - desired media chunk queue size (always < mediaChunks.size()) - * @return endTimeUs of first chunk removed - */ - private long discardMediaChunks(int preferredQueueSize) { - Log.d(TAG, "discardChunksToIndex() - preferredQueueSize " + preferredQueueSize - + " currentSize " + mediaChunks.size() - + " write: "+sampleQueues[primarySampleQueueIndex].getWriteIndex() - + " read: "+sampleQueues[primarySampleQueueIndex].getReadIndex() + public void dumpCurrentChunkList() { + Log.d(TAG, "Dump MediaChunks - trackType: " + trackType + " chunk count: " + mediaChunks.size() + + " primary sample write: "+sampleQueues[primarySampleQueueIndex].getWriteIndex() + + " primary sample read: "+sampleQueues[primarySampleQueueIndex].getReadIndex() ); for (int i=0; i 0) { - firstRemovedChunkIndex++; - preferredQueueSize--; - } - - HlsMediaChunk firstRemovedChunk = mediaChunks.get(firstRemovedChunkIndex); - Util.removeRange(mediaChunks, firstRemovedChunkIndex, mediaChunks.size() - 1); - Log.d(TAG, "discardChunksToIndex() - discard from: " + firstRemovedChunk.getFirstPrimarySampleIndex()); - - sampleQueues[primarySampleQueueIndex].discardUpstreamSamples(firstRemovedChunk.getFirstPrimarySampleIndex()); - - return firstRemovedChunk.endTimeUs; } /** Returns whether samples have been read from primary sample queue of the indicated chunk */ private boolean haveReadFromMediaChunk(int mediaChunkIndex) { HlsMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); - return sampleQueues[primarySampleQueueIndex].getReadIndex() > mediaChunk.getFirstPrimarySampleIndex(); + boolean haveRead = false; + int [] firstSamples = mediaChunk.getFirstSampleIndexes(); + for (int i=0; i < firstSamples.length; i++) { + haveRead = haveRead || sampleQueues[i].getReadIndex() > firstSamples[i]; + } + return haveRead; } // Loader.Callback implementation. @@ -887,8 +916,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueueMappingDoneByType.clear(); } this.chunkUid = chunkUid; - for (SampleQueue sampleQueue : sampleQueues) { + HlsMediaChunk loadingChunk = findChunkMatching(chunkUid); + for (int i=0; i < sampleQueues.length; i++) { + SampleQueue sampleQueue = sampleQueues[i]; sampleQueue.sourceId(chunkUid); + loadingChunk.setFirstSampleIndex(i, sampleQueue.getWriteIndex()); } if (shouldSpliceIn) { for (SampleQueue sampleQueue : sampleQueues) { @@ -974,6 +1006,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1); sampleQueueTrackIds[trackCount] = id; sampleQueues = Util.nullSafeArrayAppend(sampleQueues, trackOutput); + HlsMediaChunk mediaChunk = findChunkMatching(chunkUid); + mediaChunk.setFirstSampleIndex(trackCount, 0); sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1); sampleQueueIsAudioVideoFlags[trackCount] = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; @@ -1016,23 +1050,35 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } /** - * Get the SampleQueue write position index. Used to associate group of + * Get the SampleQueue write position indexes. Used to associate group of * samples with a MediaChunk. * - * @return write position {@link SampleQueue#getWriteIndex()}, or 0 (safe bet) if sample queues not created + * @return array of write positions {@link SampleQueue#getWriteIndex()}, by sample queue index */ - int getPrimaryTrackWritePosition() { - int indexValue = 0; + int [] getTrackWritePositions() { + int [] writePositions; - if (primaryTrackGroupIndex != C.INDEX_UNSET && prepared) { - int sampleQueueIndex = trackGroupToSampleQueueIndex[primaryTrackGroupIndex]; - indexValue = sampleQueues[sampleQueueIndex].getWriteIndex(); + writePositions = new int[sampleQueues.length]; + for (int i=0; i < sampleQueues.length; i++) { + writePositions[i] = sampleQueues[i].getWriteIndex(); } - return indexValue; + + return writePositions; } // Internal methods. + private HlsMediaChunk findChunkMatching(int chunkUid) { + ListIterator iter = mediaChunks.listIterator(mediaChunks.size()); + while (iter.hasPrevious()) { + HlsMediaChunk chunk = (HlsMediaChunk) iter.previous(); + if (chunk.uid == chunkUid) { + return chunk; + } + } + return null; + } + private void updateSampleStreams(@NullableType SampleStream[] streams) { hlsSampleStreams.clear(); for (SampleStream stream : streams) { @@ -1082,6 +1128,21 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Tracks are created using media segment information. buildTracksFromSampleStreams(); setIsPrepared(); + + Log.d(TAG, "Wrapper prepared - trackType: " + trackType + " tracks: " + trackGroups.length + " sample queues: " + sampleQueues.length + " sample streams: " + hlsSampleStreams.size()); + + for (int i = 0; i < trackGroups.length; i++) { + TrackGroup group = trackGroups.get(i); + int sampleQueueIndex = this.trackGroupToSampleQueueIndex[i]; + if (sampleQueueIndex == C.INDEX_UNSET) { + Log.d(TAG, " track group " + i + " is unmapped, tracks: " + group.length); + } else { + Log.d(TAG, " track group " + i + " is maped to sample queue: " + sampleQueueIndex + " ,tracks: " + group.length); + for (int j=0; j Date: Wed, 21 Aug 2019 16:38:31 -0700 Subject: [PATCH 0003/1052] Remove unused methods, comment cleanup. --- .../exoplayer2/source/hls/HlsMediaChunk.java | 10 ++++++++-- .../source/hls/HlsSampleStreamWrapper.java | 17 ----------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index aeccc6b8db..0546fa5429 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -300,7 +300,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } /** - * Return the index of the first sample from the primary sample stream for this media chunk + * Return the indexes of the first samples from each sample queue for this media chunk * * @return sample index {@link SampleQueue#getWriteIndex()} */ @@ -308,6 +308,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return firstSampleIndexes; } + /** + * Set the index of the first sample written to a sample queue from this media chunk, + * indexed by the caller's stream index for the sample queue + * + * @param streamIndex - caller's index for the {@link SampleQueue} + * @param firstSampleIndex - index value to store + */ void setFirstSampleIndex(int streamIndex, int firstSampleIndex) { firstSampleIndexes = Arrays.copyOf(firstSampleIndexes, streamIndex + 1); firstSampleIndexes[streamIndex] = firstSampleIndex; @@ -330,7 +337,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; initDataLoadRequired = false; output.init(uid, shouldSpliceIn, /* reusingExtractor= */ true); } - firstSampleIndexes = output.getTrackWritePositions(); maybeLoadInitData(); if (!loadCanceled) { if (!hasGapTag) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index cb71cd5b36..8d6db4b112 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -1049,23 +1049,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } - /** - * Get the SampleQueue write position indexes. Used to associate group of - * samples with a MediaChunk. - * - * @return array of write positions {@link SampleQueue#getWriteIndex()}, by sample queue index - */ - int [] getTrackWritePositions() { - int [] writePositions; - - writePositions = new int[sampleQueues.length]; - for (int i=0; i < sampleQueues.length; i++) { - writePositions[i] = sampleQueues[i].getWriteIndex(); - } - - return writePositions; - } - // Internal methods. private HlsMediaChunk findChunkMatching(int chunkUid) { From 5bead4acbbe46ee382c61a830af02b6fb687eedd Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 10 Dec 2019 11:26:41 +0000 Subject: [PATCH 0004/1052] Make DefaultTimeBar exclude itself for gestures Issue: #6685 PiperOrigin-RevId: 284736041 --- RELEASENOTES.md | 5 +++++ .../android/exoplayer2/ui/DefaultTimeBar.java | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5add56ca08..0642a9a921 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,10 @@ # Release notes # +### 2.11.1 (2019-12-20) ### + +* UI: Exclude `DefaultTimeBar` region from system gesture detection + ([#6685](https://github.com/google/ExoPlayer/issues/6685)). + ### 2.11.0 (2019-12-11) ### * Core library: diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 8b737bc006..89bcaf84bc 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -36,12 +36,15 @@ import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; import androidx.annotation.ColorInt; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import java.util.Collections; import java.util.Formatter; import java.util.Locale; import java.util.concurrent.CopyOnWriteArraySet; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A time bar that shows a current position, buffered position, duration and ad markers. @@ -199,6 +202,7 @@ public class DefaultTimeBar extends View implements TimeBar { private int keyCountIncrement; private long keyTimeIncrement; private int lastCoarseScrubXPosition; + @MonotonicNonNull private Rect lastExclusionRectangle; private boolean scrubbing; private long scrubPosition; @@ -604,6 +608,9 @@ public class DefaultTimeBar extends View implements TimeBar { seekBounds.set(seekLeft, barY, seekRight, barY + touchTargetHeight); progressBar.set(seekBounds.left + scrubberPadding, progressY, seekBounds.right - scrubberPadding, progressY + barHeight); + if (Util.SDK_INT >= 29) { + setSystemGestureExclusionRectsV29(width, height); + } update(); } @@ -834,6 +841,18 @@ public class DefaultTimeBar extends View implements TimeBar { } } + @RequiresApi(29) + private void setSystemGestureExclusionRectsV29(int width, int height) { + if (lastExclusionRectangle != null + && lastExclusionRectangle.width() == width + && lastExclusionRectangle.height() == height) { + // Allocating inside onLayout is considered a DrawAllocation lint error, so avoid if possible. + return; + } + lastExclusionRectangle = new Rect(/* left= */ 0, /* top= */ 0, width, height); + setSystemGestureExclusionRects(Collections.singletonList(lastExclusionRectangle)); + } + private String getProgressText() { return Util.getStringForTime(formatBuilder, formatter, position); } From 53d30d80a599bcc0f64ceeec0a40916cc587dedb Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 13 Dec 2019 10:21:32 +0000 Subject: [PATCH 0005/1052] Fix bug where C.TIME_UNSET was used for calcutations. The presentationTimeOffsetMs may be C.TIME_UNSET for VOD content and shouldn't be used in calculations for the windowStartTime. PiperOrigin-RevId: 285363095 --- .../android/exoplayer2/source/dash/DashMediaSource.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 352131d70a..3f179d0e7e 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -1010,8 +1010,13 @@ public final class DashMediaSource extends BaseMediaSource { windowDurationUs / 2); } } - long windowStartTimeMs = manifest.availabilityStartTimeMs - + manifest.getPeriod(0).startMs + C.usToMs(currentStartTimeUs); + long windowStartTimeMs = C.TIME_UNSET; + if (manifest.availabilityStartTimeMs != C.TIME_UNSET) { + windowStartTimeMs = + manifest.availabilityStartTimeMs + + manifest.getPeriod(0).startMs + + C.usToMs(currentStartTimeUs); + } DashTimeline timeline = new DashTimeline( manifest.availabilityStartTimeMs, From 4653592d0eb61f8289617026edda5c57bb506405 Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 13 Dec 2019 13:02:58 +0000 Subject: [PATCH 0006/1052] Propagate HTTP request headers through CacheDataSource This has been broken since https://github.com/google/ExoPlayer/commit/c3d6be3afdd7c0ca68dba15e443bc64aa3f61073 and broken for ICY (where I noticed the problem) since https://github.com/google/ExoPlayer/commit/5695bae9d8fde5e156fd38fa552e266c5611c71f. ICY symptom is that we see no repeated metadata, because the Icy-MetaData:1 header doesn't make it to the server so we never get back icy-metaint. PiperOrigin-RevId: 285379234 --- RELEASENOTES.md | 1 + .../upstream/cache/CacheDataSource.java | 31 ++++++++++++++-- .../upstream/cache/CacheDataSourceTest.java | 36 ++++++++++++++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0642a9a921..198de62178 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,6 +4,7 @@ * UI: Exclude `DefaultTimeBar` region from system gesture detection ([#6685](https://github.com/google/ExoPlayer/issues/6685)). +* Propagate HTTP request headers through `CacheDataSource`. ### 2.11.0 (2019-12-11) ### diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index 541c3b2d9d..94ec2c6dff 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -138,7 +138,8 @@ public final class CacheDataSource implements DataSource { @Nullable private Uri actualUri; @HttpMethod private int httpMethod; @Nullable private byte[] httpBody; - private int flags; + private Map httpRequestHeaders = Collections.emptyMap(); + @DataSpec.Flags private int flags; @Nullable private String key; private long readPosition; private long bytesRemaining; @@ -263,6 +264,7 @@ public final class CacheDataSource implements DataSource { actualUri = getRedirectedUriOrDefault(cache, key, /* defaultUri= */ uri); httpMethod = dataSpec.httpMethod; httpBody = dataSpec.httpBody; + httpRequestHeaders = dataSpec.httpRequestHeaders; flags = dataSpec.flags; readPosition = dataSpec.position; @@ -353,6 +355,10 @@ public final class CacheDataSource implements DataSource { actualUri = null; httpMethod = DataSpec.HTTP_METHOD_GET; httpBody = null; + httpRequestHeaders = Collections.emptyMap(); + flags = 0; + readPosition = 0; + key = null; notifyBytesRead(); try { closeCurrentSource(); @@ -399,7 +405,15 @@ public final class CacheDataSource implements DataSource { nextDataSource = upstreamDataSource; nextDataSpec = new DataSpec( - uri, httpMethod, httpBody, readPosition, readPosition, bytesRemaining, key, flags); + uri, + httpMethod, + httpBody, + readPosition, + readPosition, + bytesRemaining, + key, + flags, + httpRequestHeaders); } else if (nextSpan.isCached) { // Data is cached, read from cache. Uri fileUri = Uri.fromFile(nextSpan.file); @@ -408,6 +422,8 @@ public final class CacheDataSource implements DataSource { if (bytesRemaining != C.LENGTH_UNSET) { length = Math.min(length, bytesRemaining); } + // Deliberately skip the HTTP-related parameters since we're reading from the cache, not + // making an HTTP request. nextDataSpec = new DataSpec(fileUri, readPosition, filePosition, length, key, flags); nextDataSource = cacheReadDataSource; } else { @@ -422,7 +438,16 @@ public final class CacheDataSource implements DataSource { } } nextDataSpec = - new DataSpec(uri, httpMethod, httpBody, readPosition, readPosition, length, key, flags); + new DataSpec( + uri, + httpMethod, + httpBody, + readPosition, + readPosition, + length, + key, + flags, + httpRequestHeaders); if (cacheWriteDataSource != null) { nextDataSource = cacheWriteDataSource; } else { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 83104119ad..27438fcac3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -34,6 +34,8 @@ import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import java.util.NavigableSet; import org.junit.After; import org.junit.Before; @@ -48,20 +50,27 @@ public final class CacheDataSourceTest { private static final int CACHE_FRAGMENT_SIZE = 3; private static final String DATASPEC_KEY = "dataSpecKey"; + // Test data private Uri testDataUri; + private Map httpRequestHeaders; private DataSpec unboundedDataSpec; private DataSpec boundedDataSpec; private DataSpec unboundedDataSpecWithKey; private DataSpec boundedDataSpecWithKey; private String defaultCacheKey; private String customCacheKey; + + // Dependencies of SUT private CacheKeyFactory cacheKeyFactory; private File tempFolder; private SimpleCache cache; + private FakeDataSource upstreamDataSource; @Before public void setUp() throws Exception { testDataUri = Uri.parse("https://www.test.com/data"); + httpRequestHeaders = new HashMap<>(); + httpRequestHeaders.put("Test-key", "Test-val"); unboundedDataSpec = buildDataSpec(/* unbounded= */ true, /* key= */ null); boundedDataSpec = buildDataSpec(/* unbounded= */ false, /* key= */ null); unboundedDataSpecWithKey = buildDataSpec(/* unbounded= */ true, DATASPEC_KEY); @@ -69,9 +78,11 @@ public final class CacheDataSourceTest { defaultCacheKey = CacheUtil.DEFAULT_CACHE_KEY_FACTORY.buildCacheKey(unboundedDataSpec); customCacheKey = "customKey." + defaultCacheKey; cacheKeyFactory = dataSpec -> customCacheKey; + tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + upstreamDataSource = new FakeDataSource(); } @After @@ -111,6 +122,19 @@ public final class CacheDataSourceTest { assertCacheAndRead(boundedDataSpec, /* unknownLength= */ false); } + @Test + public void testPropagatesHttpHeadersUpstream() throws Exception { + CacheDataSource cacheDataSource = + createCacheDataSource(/* setReadException= */ false, /* unknownLength= */ false); + DataSpec dataSpec = buildDataSpec(/* position= */ 2, /* length= */ 5); + cacheDataSource.open(dataSpec); + + DataSpec[] upstreamDataSpecs = upstreamDataSource.getAndClearOpenedDataSpecs(); + + assertThat(upstreamDataSpecs).hasLength(1); + assertThat(upstreamDataSpecs[0].httpRequestHeaders).isEqualTo(this.httpRequestHeaders); + } + @Test public void testUnsatisfiableRange() throws Exception { // Bounded request but the content length is unknown. This forces all data to be cached but not @@ -572,9 +596,8 @@ public final class CacheDataSourceTest { @CacheDataSource.Flags int flags, CacheDataSink cacheWriteDataSink, CacheKeyFactory cacheKeyFactory) { - FakeDataSource upstream = new FakeDataSource(); FakeData fakeData = - upstream + upstreamDataSource .getDataSet() .newDefaultData() .setSimulateUnknownLength(unknownLength) @@ -584,7 +607,7 @@ public final class CacheDataSourceTest { } return new CacheDataSource( cache, - upstream, + upstreamDataSource, new FileDataSource(), cacheWriteDataSink, flags, @@ -602,6 +625,11 @@ public final class CacheDataSourceTest { private DataSpec buildDataSpec(long position, long length, @Nullable String key) { return new DataSpec( - testDataUri, position, length, key, DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); + testDataUri, + position, + length, + key, + DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION, + httpRequestHeaders); } } From d8951a2a38e06acca52b69383168627f32144898 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 13 Dec 2019 18:16:59 +0000 Subject: [PATCH 0007/1052] Add an additional sanity check to FakeExtractorOutput PiperOrigin-RevId: 285422885 --- .../android/exoplayer2/testutil/FakeExtractorOutput.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java index 4022a0ccc1..9394848198 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java @@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertWithMessage; import android.content.Context; import android.util.SparseArray; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.SeekMap; import java.io.File; @@ -67,6 +68,9 @@ public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpab @Override public void seekMap(SeekMap seekMap) { + if (seekMap.isSeekable() && seekMap.getDurationUs() == C.TIME_UNSET) { + throw new IllegalStateException("SeekMap cannot be seekable and have an unknown duration"); + } this.seekMap = seekMap; } From 250a5deab503c10da06c0de918b7ce1f20943b92 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 16 Dec 2019 11:17:11 +0000 Subject: [PATCH 0008/1052] Add more SeekMap assertions, and "fix" MatroskaExtractor In MatroskaExtractor, if the last cue time exceeds the duration specified in the header, then we end up generating a negative duration chunk as the last item in the SeekMap. We should probably not do this, so drop it instead. Note: Matroska does have a CueDuration element, but it's not used in the one problematic file I've found. PiperOrigin-RevId: 285738418 --- .../extractor/mkv/MatroskaExtractor.java | 10 ++++ .../src/test/assets/mkv/sample.mkv.0.dump | 2 +- .../src/test/assets/mkv/sample.mkv.1.dump | 52 ++++++++----------- .../src/test/assets/mkv/sample.mkv.2.dump | 30 ++++------- .../src/test/assets/mkv/sample.mkv.3.dump | 2 +- .../testutil/FakeExtractorOutput.java | 14 ++++- 6 files changed, 57 insertions(+), 53 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 403f6c3d41..b30fbf105e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -1635,6 +1635,16 @@ public class MatroskaExtractor implements Extractor { sizes[cuePointsSize - 1] = (int) (segmentContentPosition + segmentContentSize - offsets[cuePointsSize - 1]); durationsUs[cuePointsSize - 1] = durationUs - timesUs[cuePointsSize - 1]; + + long lastDurationUs = durationsUs[cuePointsSize - 1]; + if (lastDurationUs <= 0) { + Log.w(TAG, "Discarding last cue point with unexpected duration: " + lastDurationUs); + sizes = Arrays.copyOf(sizes, sizes.length - 1); + offsets = Arrays.copyOf(offsets, offsets.length - 1); + durationsUs = Arrays.copyOf(durationsUs, durationsUs.length - 1); + timesUs = Arrays.copyOf(timesUs, timesUs.length - 1); + } + cueTimesUs = null; cueClusterPositions = null; return new ChunkIndex(sizes, offsets, durationsUs, timesUs); diff --git a/library/core/src/test/assets/mkv/sample.mkv.0.dump b/library/core/src/test/assets/mkv/sample.mkv.0.dump index 847799396d..d91f845199 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.0.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.0.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 1072000 + duration = 1104000 getPosition(0) = [[timeUs=67000, position=5576]] numberOfTracks = 2 track 1: diff --git a/library/core/src/test/assets/mkv/sample.mkv.1.dump b/library/core/src/test/assets/mkv/sample.mkv.1.dump index 5caa638437..d9013a762e 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.1.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.1.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 1072000 + duration = 1104000 getPosition(0) = [[timeUs=67000, position=5576]] numberOfTracks = 2 track 1: @@ -27,93 +27,85 @@ track 1: initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B - total output bytes = 30995 - sample count = 22 + total output bytes = 29422 + sample count = 20 sample 0: - time = 334000 - flags = 0 - data = length 953, hash 7160C661 - sample 1: - time = 300000 - flags = 0 - data = length 620, hash 7A7AE07C - sample 2: time = 367000 flags = 0 data = length 405, hash 5CC7F4E7 - sample 3: + sample 1: time = 500000 flags = 0 data = length 4852, hash 9DB6979D - sample 4: + sample 2: time = 467000 flags = 0 data = length 547, hash E31A6979 - sample 5: + sample 3: time = 434000 flags = 0 data = length 570, hash FEC40D00 - sample 6: + sample 4: time = 634000 flags = 0 data = length 5525, hash 7C478F7E - sample 7: + sample 5: time = 567000 flags = 0 data = length 1082, hash DA07059A - sample 8: + sample 6: time = 534000 flags = 0 data = length 807, hash 93478E6B - sample 9: + sample 7: time = 600000 flags = 0 data = length 744, hash 9A8E6026 - sample 10: + sample 8: time = 767000 flags = 0 data = length 4732, hash C73B23C0 - sample 11: + sample 9: time = 700000 flags = 0 data = length 1004, hash 8A19A228 - sample 12: + sample 10: time = 667000 flags = 0 data = length 794, hash 8126022C - sample 13: + sample 11: time = 734000 flags = 0 data = length 645, hash F08300E5 - sample 14: + sample 12: time = 900000 flags = 0 data = length 2684, hash 727FE378 - sample 15: + sample 13: time = 834000 flags = 0 data = length 787, hash 419A7821 - sample 16: + sample 14: time = 800000 flags = 0 data = length 649, hash 5C159346 - sample 17: + sample 15: time = 867000 flags = 0 data = length 509, hash F912D655 - sample 18: + sample 16: time = 1034000 flags = 0 data = length 1226, hash 29815C21 - sample 19: + sample 17: time = 967000 flags = 0 data = length 898, hash D997AD0A - sample 20: + sample 18: time = 934000 flags = 0 data = length 476, hash A0423645 - sample 21: + sample 19: time = 1000000 flags = 0 data = length 486, hash DDF32CBB diff --git a/library/core/src/test/assets/mkv/sample.mkv.2.dump b/library/core/src/test/assets/mkv/sample.mkv.2.dump index de4e2a58bf..b0f943e2f2 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.2.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.2.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 1072000 + duration = 1104000 getPosition(0) = [[timeUs=67000, position=5576]] numberOfTracks = 2 track 1: @@ -27,49 +27,41 @@ track 1: initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B - total output bytes = 10158 - sample count = 11 + total output bytes = 8360 + sample count = 9 sample 0: - time = 700000 - flags = 0 - data = length 1004, hash 8A19A228 - sample 1: - time = 667000 - flags = 0 - data = length 794, hash 8126022C - sample 2: time = 734000 flags = 0 data = length 645, hash F08300E5 - sample 3: + sample 1: time = 900000 flags = 0 data = length 2684, hash 727FE378 - sample 4: + sample 2: time = 834000 flags = 0 data = length 787, hash 419A7821 - sample 5: + sample 3: time = 800000 flags = 0 data = length 649, hash 5C159346 - sample 6: + sample 4: time = 867000 flags = 0 data = length 509, hash F912D655 - sample 7: + sample 5: time = 1034000 flags = 0 data = length 1226, hash 29815C21 - sample 8: + sample 6: time = 967000 flags = 0 data = length 898, hash D997AD0A - sample 9: + sample 7: time = 934000 flags = 0 data = length 476, hash A0423645 - sample 10: + sample 8: time = 1000000 flags = 0 data = length 486, hash DDF32CBB diff --git a/library/core/src/test/assets/mkv/sample.mkv.3.dump b/library/core/src/test/assets/mkv/sample.mkv.3.dump index 6034c54dec..460aca0e90 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.3.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.3.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 1072000 + duration = 1104000 getPosition(0) = [[timeUs=67000, position=5576]] numberOfTracks = 2 track 1: diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java index 9394848198..b5e90dc3ea 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java @@ -68,8 +68,18 @@ public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpab @Override public void seekMap(SeekMap seekMap) { - if (seekMap.isSeekable() && seekMap.getDurationUs() == C.TIME_UNSET) { - throw new IllegalStateException("SeekMap cannot be seekable and have an unknown duration"); + if (seekMap.isSeekable()) { + if (seekMap.getDurationUs() == C.TIME_UNSET) { + throw new IllegalStateException("SeekMap cannot be seekable and have an unknown duration"); + } + SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(0); + if (!seekPoints.first.equals(seekPoints.second)) { + throw new IllegalStateException("SeekMap defines two seek points for t=0"); + } + seekPoints = seekMap.getSeekPoints(seekMap.getDurationUs()); + if (!seekPoints.first.equals(seekPoints.second)) { + throw new IllegalStateException("SeekMap defines two seek points for t=durationUs"); + } } this.seekMap = seekMap; } From 88be178e0fa8139a36d127a1a5cdaab4fbd3cda2 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 16 Dec 2019 15:35:06 +0000 Subject: [PATCH 0009/1052] Manual rollback of https://github.com/google/ExoPlayer/commit/b3f485d7d9c08e39574b72a949166ee4834c3b24 It's technically possible to output a seekable SeekMap with unknown duration. This can occur if the media defines seek points but doesn't define either the overall duration or the duration of the media from the last seek point to the end. PiperOrigin-RevId: 285769121 --- .../exoplayer2/testutil/FakeExtractorOutput.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java index b5e90dc3ea..0502707682 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java @@ -69,16 +69,16 @@ public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpab @Override public void seekMap(SeekMap seekMap) { if (seekMap.isSeekable()) { - if (seekMap.getDurationUs() == C.TIME_UNSET) { - throw new IllegalStateException("SeekMap cannot be seekable and have an unknown duration"); - } SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(0); if (!seekPoints.first.equals(seekPoints.second)) { throw new IllegalStateException("SeekMap defines two seek points for t=0"); } - seekPoints = seekMap.getSeekPoints(seekMap.getDurationUs()); - if (!seekPoints.first.equals(seekPoints.second)) { - throw new IllegalStateException("SeekMap defines two seek points for t=durationUs"); + long durationUs = seekMap.getDurationUs(); + if (durationUs != C.TIME_UNSET) { + seekPoints = seekMap.getSeekPoints(durationUs); + if (!seekPoints.first.equals(seekPoints.second)) { + throw new IllegalStateException("SeekMap defines two seek points for t=durationUs"); + } } } this.seekMap = seekMap; From 5e822e160ed6831d4ca3754e30c1a1561f8f4b4a Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 16 Dec 2019 16:18:01 +0000 Subject: [PATCH 0010/1052] FakeTrackOutput: Throw if sample size exceeds maxInputSize This indicates the extractor has output a Format with a specified maxInputSize that's too small. Failing in FakeTrackOutput ensures this doesn't happen during Extractor tests. PiperOrigin-RevId: 285776069 --- .../google/android/exoplayer2/testutil/FakeTrackOutput.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java index 4dd00557ae..f78e835b48 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java @@ -93,6 +93,12 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { @Override public void sampleMetadata(long timeUs, @C.BufferFlags int flags, int size, int offset, CryptoData cryptoData) { + if (format == null) { + throw new IllegalStateException("TrackOutput must receive format before sampleMetadata"); + } + if (format.maxInputSize != Format.NO_VALUE && size > format.maxInputSize) { + throw new IllegalStateException("Sample size exceeds Format.maxInputSize"); + } sampleTimesUs.add(timeUs); sampleFlags.add(flags); sampleStartOffsets.add(sampleData.length - offset - size); From 739517bd5e32c7443c03d968354475e109356456 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 13 Dec 2019 20:29:54 +0000 Subject: [PATCH 0011/1052] Fix FLAC seeking when the last seek point is a placeholder PiperOrigin-RevId: 285449865 --- extensions/flac/src/main/jni/flac_parser.cc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/flac/src/main/jni/flac_parser.cc b/extensions/flac/src/main/jni/flac_parser.cc index b920560f3a..f39e4bd1f7 100644 --- a/extensions/flac/src/main/jni/flac_parser.cc +++ b/extensions/flac/src/main/jni/flac_parser.cc @@ -462,8 +462,9 @@ bool FLACParser::getSeekPositions(int64_t timeUs, if (sampleNumber <= targetSampleNumber) { result[0] = (sampleNumber * 1000000LL) / sampleRate; result[1] = firstFrameOffset + points[i - 1].stream_offset; - if (sampleNumber == targetSampleNumber || i >= length) { - // exact seek, or no following seek point. + if (sampleNumber == targetSampleNumber || i >= length || + points[i].sample_number == -1) { // placeholder + // exact seek, or no following non-placeholder seek point result[2] = result[0]; result[3] = result[1]; } else { From 0a701abafe2efb10b0ef840989f6e2ba232c74c8 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 17 Dec 2019 17:00:54 +0000 Subject: [PATCH 0012/1052] Retain AV1 constructor for DefaultRenderersFactory Issue: #6773 PiperOrigin-RevId: 285990377 --- RELEASENOTES.md | 2 ++ library/core/proguard-rules.txt | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 198de62178..58416fbcef 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,8 @@ * UI: Exclude `DefaultTimeBar` region from system gesture detection ([#6685](https://github.com/google/ExoPlayer/issues/6685)). * Propagate HTTP request headers through `CacheDataSource`. +* AV1 extension: Fix ProGuard rules + ([6773](https://github.com/google/ExoPlayer/issues/6773)). ### 2.11.0 (2019-12-11) ### diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 67646be956..bfd691259b 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -5,6 +5,10 @@ -keepclassmembers class com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer { (long, android.os.Handler, com.google.android.exoplayer2.video.VideoRendererEventListener, int); } +-dontnote com.google.android.exoplayer2.ext.av1.Libgav1VideoRenderer +-keepclassmembers class com.google.android.exoplayer2.ext.av1.Libgav1VideoRenderer { + (long, android.os.Handler, com.google.android.exoplayer2.video.VideoRendererEventListener, int); +} -dontnote com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer { (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); From 166e389c563af4cd4cff3da0953e9fbf2fefd9ec Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 17 Dec 2019 17:13:53 +0000 Subject: [PATCH 0013/1052] Suppress ProGuard warnings about javax.annotation These annotations are compile-only - so we don't mind they're not accessible at runtime. PiperOrigin-RevId: 285993063 --- RELEASENOTES.md | 2 ++ library/core/proguard-rules.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 58416fbcef..cfaca5088d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -7,6 +7,8 @@ * Propagate HTTP request headers through `CacheDataSource`. * AV1 extension: Fix ProGuard rules ([6773](https://github.com/google/ExoPlayer/issues/6773)). +* Suppress ProGuard warnings for compile-time `javax.annotation` package + ([#6771](https://github.com/google/ExoPlayer/issues/6771)). ### 2.11.0 (2019-12-11) ### diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index bfd691259b..ab4af32da4 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -65,6 +65,7 @@ # Don't warn about checkerframework and Kotlin annotations -dontwarn org.checkerframework.** -dontwarn kotlin.annotations.jvm.** +-dontwarn javax.annotation.** # Some members of this class are being accessed from native methods. Keep them unobfuscated. -keep class com.google.android.exoplayer2.ext.video.VideoDecoderOutputBuffer { From c9109f437c506dd0eb5bdb19728d7d04d3ed202b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 18 Dec 2019 10:24:10 +0000 Subject: [PATCH 0014/1052] Ensure raw resources are kept R8 does constant folding, so we need to keep buildRawResourceUri to ensure that resources passed to it are kept. PiperOrigin-RevId: 286153875 --- RELEASENOTES.md | 2 ++ library/core/proguard-rules.txt | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cfaca5088d..fe8a639d1a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -9,6 +9,8 @@ ([6773](https://github.com/google/ExoPlayer/issues/6773)). * Suppress ProGuard warnings for compile-time `javax.annotation` package ([#6771](https://github.com/google/ExoPlayer/issues/6771)). +* Fix proguard rules for R8 to ensure raw resources used with + `RawResourceDataSource` are kept. ### 2.11.0 (2019-12-11) ### diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index ab4af32da4..494837c3e9 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -1,5 +1,10 @@ # Proguard rules specific to the core module. +# Constant folding for resource integers may mean that a resource passed to this method appears to be unused. Keep the method to prevent this from happening. +-keep class com.google.android.exoplayer2.upstream.RawResourceDataSource { + public static android.net.Uri buildRawResourceUri(int); +} + # Constructors accessed via reflection in DefaultRenderersFactory -dontnote com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer -keepclassmembers class com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer { @@ -69,5 +74,5 @@ # Some members of this class are being accessed from native methods. Keep them unobfuscated. -keep class com.google.android.exoplayer2.ext.video.VideoDecoderOutputBuffer { - *; + *; } From b4873e55e129df8e05841d151e9653500dbc2b81 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 18 Dec 2019 10:47:42 +0000 Subject: [PATCH 0015/1052] Fix keep rule for VideoDecoderOutputBuffer PiperOrigin-RevId: 286156361 --- RELEASENOTES.md | 2 ++ library/core/proguard-rules.txt | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fe8a639d1a..0fcff4bb7d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -11,6 +11,8 @@ ([#6771](https://github.com/google/ExoPlayer/issues/6771)). * Fix proguard rules for R8 to ensure raw resources used with `RawResourceDataSource` are kept. +* Fix proguard rules to keep `VideoDecoderOutputBuffer` for video decoder + extensions. ### 2.11.0 (2019-12-11) ### diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 494837c3e9..fd4e196945 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -5,6 +5,11 @@ public static android.net.Uri buildRawResourceUri(int); } +# Some members of this class are being accessed from native methods. Keep them unobfuscated. +-keep class com.google.android.exoplayer2.video.VideoDecoderOutputBuffer { + *; +} + # Constructors accessed via reflection in DefaultRenderersFactory -dontnote com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer -keepclassmembers class com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer { @@ -71,8 +76,3 @@ -dontwarn org.checkerframework.** -dontwarn kotlin.annotations.jvm.** -dontwarn javax.annotation.** - -# Some members of this class are being accessed from native methods. Keep them unobfuscated. --keep class com.google.android.exoplayer2.ext.video.VideoDecoderOutputBuffer { - *; -} From 1106aba3515db433f89aabb5f038350614408868 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 18 Dec 2019 16:56:31 +0000 Subject: [PATCH 0016/1052] Add omitted release note for 2.11 PiperOrigin-RevId: 286201458 --- RELEASENOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0fcff4bb7d..57eb4f9519 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -44,6 +44,10 @@ * Fix issue where player errors are thrown too early at playlist transitions ([#5407](https://github.com/google/ExoPlayer/issues/5407)). * Add `Format` and renderer support flags to renderer `ExoPlaybackException`s. + * Where there are multiple platform decoders for a given MIME type, prefer to + use one that advertises support for the profile and level of the media being + played over one that does not, even if it does not come first in the + `MediaCodecList`. * DRM: * Inject `DrmSessionManager` into the `MediaSources` instead of `Renderers`. This allows each `MediaSource` in a `ConcatenatingMediaSource` to use a From 5d2ca02c5a628f33a01fa7501cd762b6ed165dc1 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 18 Dec 2019 18:09:42 +0000 Subject: [PATCH 0017/1052] Unwrap all nested IntDef values This seems to work with R8 but interact badly with ProGuard. issue:#6771 PiperOrigin-RevId: 286215262 --- .../mediacodec/MediaCodecRenderer.java | 4 +- .../exoplayer2/text/ssa/SsaDecoder.java | 64 +++++------ .../android/exoplayer2/text/ssa/SsaStyle.java | 103 ++++++++++-------- .../exoplayer2/text/webvtt/WebvttCue.java | 97 +++++++++-------- .../text/webvtt/WebvttCueParser.java | 12 +- .../exoplayer2/source/hls/HlsMediaPeriod.java | 4 +- .../exoplayer2/source/hls/HlsMediaSource.java | 43 ++++++-- .../source/hls/HlsMetadataType.java | 38 ------- .../source/hls/HlsSampleStreamWrapper.java | 11 +- .../source/hls/HlsMediaPeriodTest.java | 2 +- 10 files changed, 192 insertions(+), 186 deletions(-) delete mode 100644 library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMetadataType.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 820f9f003e..6c405f7ced 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -59,9 +59,7 @@ import java.util.List; */ public abstract class MediaCodecRenderer extends BaseRenderer { - /** - * Thrown when a failure occurs instantiating a decoder. - */ + /** Thrown when a failure occurs instantiating a decoder. */ public static class DecoderInitializationException extends Exception { private static final int CUSTOM_ERROR_CODE_BASE = -50000; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java index 917ac8e36e..eef9d2eec1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -300,12 +300,12 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { float screenWidth, float screenHeight) { @SsaStyle.SsaAlignment int alignment; - if (styleOverrides.alignment != SsaStyle.SsaAlignment.UNKNOWN) { + if (styleOverrides.alignment != SsaStyle.SSA_ALIGNMENT_UNKNOWN) { alignment = styleOverrides.alignment; } else if (style != null) { alignment = style.alignment; } else { - alignment = SsaStyle.SsaAlignment.UNKNOWN; + alignment = SsaStyle.SSA_ALIGNMENT_UNKNOWN; } @Cue.AnchorType int positionAnchor = toPositionAnchor(alignment); @Cue.AnchorType int lineAnchor = toLineAnchor(alignment); @@ -337,19 +337,19 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { @Nullable private static Layout.Alignment toTextAlignment(@SsaStyle.SsaAlignment int alignment) { switch (alignment) { - case SsaStyle.SsaAlignment.BOTTOM_LEFT: - case SsaStyle.SsaAlignment.MIDDLE_LEFT: - case SsaStyle.SsaAlignment.TOP_LEFT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: return Layout.Alignment.ALIGN_NORMAL; - case SsaStyle.SsaAlignment.BOTTOM_CENTER: - case SsaStyle.SsaAlignment.MIDDLE_CENTER: - case SsaStyle.SsaAlignment.TOP_CENTER: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: return Layout.Alignment.ALIGN_CENTER; - case SsaStyle.SsaAlignment.BOTTOM_RIGHT: - case SsaStyle.SsaAlignment.MIDDLE_RIGHT: - case SsaStyle.SsaAlignment.TOP_RIGHT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: return Layout.Alignment.ALIGN_OPPOSITE; - case SsaStyle.SsaAlignment.UNKNOWN: + case SsaStyle.SSA_ALIGNMENT_UNKNOWN: return null; default: Log.w(TAG, "Unknown alignment: " + alignment); @@ -360,19 +360,19 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { @Cue.AnchorType private static int toLineAnchor(@SsaStyle.SsaAlignment int alignment) { switch (alignment) { - case SsaStyle.SsaAlignment.BOTTOM_LEFT: - case SsaStyle.SsaAlignment.BOTTOM_CENTER: - case SsaStyle.SsaAlignment.BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: return Cue.ANCHOR_TYPE_END; - case SsaStyle.SsaAlignment.MIDDLE_LEFT: - case SsaStyle.SsaAlignment.MIDDLE_CENTER: - case SsaStyle.SsaAlignment.MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: return Cue.ANCHOR_TYPE_MIDDLE; - case SsaStyle.SsaAlignment.TOP_LEFT: - case SsaStyle.SsaAlignment.TOP_CENTER: - case SsaStyle.SsaAlignment.TOP_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: return Cue.ANCHOR_TYPE_START; - case SsaStyle.SsaAlignment.UNKNOWN: + case SsaStyle.SSA_ALIGNMENT_UNKNOWN: return Cue.TYPE_UNSET; default: Log.w(TAG, "Unknown alignment: " + alignment); @@ -383,19 +383,19 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { @Cue.AnchorType private static int toPositionAnchor(@SsaStyle.SsaAlignment int alignment) { switch (alignment) { - case SsaStyle.SsaAlignment.BOTTOM_LEFT: - case SsaStyle.SsaAlignment.MIDDLE_LEFT: - case SsaStyle.SsaAlignment.TOP_LEFT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: return Cue.ANCHOR_TYPE_START; - case SsaStyle.SsaAlignment.BOTTOM_CENTER: - case SsaStyle.SsaAlignment.MIDDLE_CENTER: - case SsaStyle.SsaAlignment.TOP_CENTER: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: return Cue.ANCHOR_TYPE_MIDDLE; - case SsaStyle.SsaAlignment.BOTTOM_RIGHT: - case SsaStyle.SsaAlignment.MIDDLE_RIGHT: - case SsaStyle.SsaAlignment.TOP_RIGHT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: return Cue.ANCHOR_TYPE_END; - case SsaStyle.SsaAlignment.UNKNOWN: + case SsaStyle.SSA_ALIGNMENT_UNKNOWN: return Cue.TYPE_UNSET; default: Log.w(TAG, "Unknown alignment: " + alignment); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java index e8070976e7..fd2cb036b7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java @@ -37,6 +37,52 @@ import java.util.regex.Pattern; private static final String TAG = "SsaStyle"; + /** + * The SSA/ASS alignments. + * + *

Allowed values: + * + *

    + *
  • {@link #SSA_ALIGNMENT_UNKNOWN} + *
  • {@link #SSA_ALIGNMENT_BOTTOM_LEFT} + *
  • {@link #SSA_ALIGNMENT_BOTTOM_CENTER} + *
  • {@link #SSA_ALIGNMENT_BOTTOM_RIGHT} + *
  • {@link #SSA_ALIGNMENT_MIDDLE_LEFT} + *
  • {@link #SSA_ALIGNMENT_MIDDLE_CENTER} + *
  • {@link #SSA_ALIGNMENT_MIDDLE_RIGHT} + *
  • {@link #SSA_ALIGNMENT_TOP_LEFT} + *
  • {@link #SSA_ALIGNMENT_TOP_CENTER} + *
  • {@link #SSA_ALIGNMENT_TOP_RIGHT} + *
+ */ + @IntDef({ + SSA_ALIGNMENT_UNKNOWN, + SSA_ALIGNMENT_BOTTOM_LEFT, + SSA_ALIGNMENT_BOTTOM_CENTER, + SSA_ALIGNMENT_BOTTOM_RIGHT, + SSA_ALIGNMENT_MIDDLE_LEFT, + SSA_ALIGNMENT_MIDDLE_CENTER, + SSA_ALIGNMENT_MIDDLE_RIGHT, + SSA_ALIGNMENT_TOP_LEFT, + SSA_ALIGNMENT_TOP_CENTER, + SSA_ALIGNMENT_TOP_RIGHT, + }) + @Documented + @Retention(SOURCE) + public @interface SsaAlignment {} + + // The numbering follows the ASS (v4+) spec (i.e. the points on the number pad). + public static final int SSA_ALIGNMENT_UNKNOWN = -1; + public static final int SSA_ALIGNMENT_BOTTOM_LEFT = 1; + public static final int SSA_ALIGNMENT_BOTTOM_CENTER = 2; + public static final int SSA_ALIGNMENT_BOTTOM_RIGHT = 3; + public static final int SSA_ALIGNMENT_MIDDLE_LEFT = 4; + public static final int SSA_ALIGNMENT_MIDDLE_CENTER = 5; + public static final int SSA_ALIGNMENT_MIDDLE_RIGHT = 6; + public static final int SSA_ALIGNMENT_TOP_LEFT = 7; + public static final int SSA_ALIGNMENT_TOP_CENTER = 8; + public static final int SSA_ALIGNMENT_TOP_RIGHT = 9; + public final String name; @SsaAlignment public final int alignment; @@ -77,22 +123,22 @@ import java.util.regex.Pattern; // Swallow the exception and return UNKNOWN below. } Log.w(TAG, "Ignoring unknown alignment: " + alignmentStr); - return SsaAlignment.UNKNOWN; + return SSA_ALIGNMENT_UNKNOWN; } private static boolean isValidAlignment(@SsaAlignment int alignment) { switch (alignment) { - case SsaAlignment.BOTTOM_CENTER: - case SsaAlignment.BOTTOM_LEFT: - case SsaAlignment.BOTTOM_RIGHT: - case SsaAlignment.MIDDLE_CENTER: - case SsaAlignment.MIDDLE_LEFT: - case SsaAlignment.MIDDLE_RIGHT: - case SsaAlignment.TOP_CENTER: - case SsaAlignment.TOP_LEFT: - case SsaAlignment.TOP_RIGHT: + case SSA_ALIGNMENT_BOTTOM_CENTER: + case SSA_ALIGNMENT_BOTTOM_LEFT: + case SSA_ALIGNMENT_BOTTOM_RIGHT: + case SSA_ALIGNMENT_MIDDLE_CENTER: + case SSA_ALIGNMENT_MIDDLE_LEFT: + case SSA_ALIGNMENT_MIDDLE_RIGHT: + case SSA_ALIGNMENT_TOP_CENTER: + case SSA_ALIGNMENT_TOP_LEFT: + case SSA_ALIGNMENT_TOP_RIGHT: return true; - case SsaAlignment.UNKNOWN: + case SSA_ALIGNMENT_UNKNOWN: default: return false; } @@ -177,7 +223,7 @@ import java.util.regex.Pattern; } public static Overrides parseFromDialogue(String text) { - @SsaAlignment int alignment = SsaAlignment.UNKNOWN; + @SsaAlignment int alignment = SSA_ALIGNMENT_UNKNOWN; PointF position = null; Matcher matcher = BRACES_PATTERN.matcher(text); while (matcher.find()) { @@ -192,7 +238,7 @@ import java.util.regex.Pattern; } try { @SsaAlignment int parsedAlignment = parseAlignmentOverride(braceContents); - if (parsedAlignment != SsaAlignment.UNKNOWN) { + if (parsedAlignment != SSA_ALIGNMENT_UNKNOWN) { alignment = parsedAlignment; } } catch (RuntimeException e) { @@ -249,36 +295,7 @@ import java.util.regex.Pattern; @SsaAlignment private static int parseAlignmentOverride(String braceContents) { Matcher matcher = ALIGNMENT_OVERRIDE_PATTERN.matcher(braceContents); - return matcher.find() ? parseAlignment(matcher.group(1)) : SsaAlignment.UNKNOWN; + return matcher.find() ? parseAlignment(matcher.group(1)) : SSA_ALIGNMENT_UNKNOWN; } } - - /** The SSA/ASS alignments. */ - @IntDef({ - SsaAlignment.UNKNOWN, - SsaAlignment.BOTTOM_LEFT, - SsaAlignment.BOTTOM_CENTER, - SsaAlignment.BOTTOM_RIGHT, - SsaAlignment.MIDDLE_LEFT, - SsaAlignment.MIDDLE_CENTER, - SsaAlignment.MIDDLE_RIGHT, - SsaAlignment.TOP_LEFT, - SsaAlignment.TOP_CENTER, - SsaAlignment.TOP_RIGHT, - }) - @Documented - @Retention(SOURCE) - /* package */ @interface SsaAlignment { - // The numbering follows the ASS (v4+) spec (i.e. the points on the number pad). - int UNKNOWN = -1; - int BOTTOM_LEFT = 1; - int BOTTOM_CENTER = 2; - int BOTTOM_RIGHT = 3; - int MIDDLE_LEFT = 4; - int MIDDLE_CENTER = 5; - int MIDDLE_RIGHT = 6; - int TOP_LEFT = 7; - int TOP_CENTER = 8; - int TOP_RIGHT = 9; - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCue.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCue.java index bfa067e322..55e568efa1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCue.java @@ -77,39 +77,40 @@ public final class WebvttCue extends Cue { @Documented @Retention(SOURCE) @IntDef({ - TextAlignment.START, - TextAlignment.CENTER, - TextAlignment.END, - TextAlignment.LEFT, - TextAlignment.RIGHT + TEXT_ALIGNMENT_START, + TEXT_ALIGNMENT_CENTER, + TEXT_ALIGNMENT_END, + TEXT_ALIGNMENT_LEFT, + TEXT_ALIGNMENT_RIGHT }) - public @interface TextAlignment { - /** - * See WebVTT's align:start. - */ - int START = 1; - /** - * See WebVTT's align:center. - */ - int CENTER = 2; - /** - * See WebVTT's align:end. - */ - int END = 3; - /** - * See WebVTT's align:left. - */ - int LEFT = 4; - /** - * See WebVTT's align:right. - */ - int RIGHT = 5; - } + public @interface TextAlignment {} + /** + * See WebVTT's align:start. + */ + public static final int TEXT_ALIGNMENT_START = 1; + + /** + * See WebVTT's align:center. + */ + public static final int TEXT_ALIGNMENT_CENTER = 2; + + /** + * See WebVTT's align:end. + */ + public static final int TEXT_ALIGNMENT_END = 3; + + /** + * See WebVTT's align:left. + */ + public static final int TEXT_ALIGNMENT_LEFT = 4; + + /** + * See WebVTT's align:right. + */ + public static final int TEXT_ALIGNMENT_RIGHT = 5; private static final String TAG = "WebvttCueBuilder"; @@ -140,7 +141,7 @@ public final class WebvttCue extends Cue { endTime = 0; text = null; // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment - textAlignment = TextAlignment.CENTER; + textAlignment = TEXT_ALIGNMENT_CENTER; line = Cue.DIMEN_UNSET; // Defaults to NUMBER (true): https://www.w3.org/TR/webvtt1/#webvtt-cue-snap-to-lines-flag lineType = Cue.LINE_TYPE_NUMBER; @@ -251,13 +252,13 @@ public final class WebvttCue extends Cue { // https://www.w3.org/TR/webvtt1/#webvtt-cue-position private static float derivePosition(@TextAlignment int textAlignment) { switch (textAlignment) { - case TextAlignment.LEFT: + case TEXT_ALIGNMENT_LEFT: return 0.0f; - case TextAlignment.RIGHT: + case TEXT_ALIGNMENT_RIGHT: return 1.0f; - case TextAlignment.START: - case TextAlignment.CENTER: - case TextAlignment.END: + case TEXT_ALIGNMENT_START: + case TEXT_ALIGNMENT_CENTER: + case TEXT_ALIGNMENT_END: default: return DEFAULT_POSITION; } @@ -267,13 +268,13 @@ public final class WebvttCue extends Cue { @AnchorType private static int derivePositionAnchor(@TextAlignment int textAlignment) { switch (textAlignment) { - case TextAlignment.LEFT: - case TextAlignment.START: + case TEXT_ALIGNMENT_LEFT: + case TEXT_ALIGNMENT_START: return Cue.ANCHOR_TYPE_START; - case TextAlignment.RIGHT: - case TextAlignment.END: + case TEXT_ALIGNMENT_RIGHT: + case TEXT_ALIGNMENT_END: return Cue.ANCHOR_TYPE_END; - case TextAlignment.CENTER: + case TEXT_ALIGNMENT_CENTER: default: return Cue.ANCHOR_TYPE_MIDDLE; } @@ -282,13 +283,13 @@ public final class WebvttCue extends Cue { @Nullable private static Alignment convertTextAlignment(@TextAlignment int textAlignment) { switch (textAlignment) { - case TextAlignment.START: - case TextAlignment.LEFT: + case TEXT_ALIGNMENT_START: + case TEXT_ALIGNMENT_LEFT: return Alignment.ALIGN_NORMAL; - case TextAlignment.CENTER: + case TEXT_ALIGNMENT_CENTER: return Alignment.ALIGN_CENTER; - case TextAlignment.END: - case TextAlignment.RIGHT: + case TEXT_ALIGNMENT_END: + case TEXT_ALIGNMENT_RIGHT: return Alignment.ALIGN_OPPOSITE; default: Log.w(TAG, "Unknown textAlignment: " + textAlignment); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index 6e5bd31b4b..b6ddf89dc3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -308,20 +308,20 @@ public final class WebvttCueParser { private static int parseTextAlignment(String s) { switch (s) { case "start": - return WebvttCue.Builder.TextAlignment.START; + return WebvttCue.Builder.TEXT_ALIGNMENT_START; case "left": - return WebvttCue.Builder.TextAlignment.LEFT; + return WebvttCue.Builder.TEXT_ALIGNMENT_LEFT; case "center": case "middle": - return WebvttCue.Builder.TextAlignment.CENTER; + return WebvttCue.Builder.TEXT_ALIGNMENT_CENTER; case "end": - return WebvttCue.Builder.TextAlignment.END; + return WebvttCue.Builder.TEXT_ALIGNMENT_END; case "right": - return WebvttCue.Builder.TextAlignment.RIGHT; + return WebvttCue.Builder.TEXT_ALIGNMENT_RIGHT; default: Log.w(TAG, "Invalid alignment value: " + s); // Default value: https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment - return WebvttCue.Builder.TextAlignment.CENTER; + return WebvttCue.Builder.TEXT_ALIGNMENT_CENTER; } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index f74d9b0b0c..3b723af435 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -75,7 +75,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper private final TimestampAdjusterProvider timestampAdjusterProvider; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private final boolean allowChunklessPreparation; - private final @HlsMetadataType int metadataType; + private final @HlsMediaSource.MetadataType int metadataType; private final boolean useSessionKeys; @Nullable private Callback callback; @@ -118,7 +118,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper Allocator allocator, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, boolean allowChunklessPreparation, - @HlsMetadataType int metadataType, + @HlsMediaSource.MetadataType int metadataType, boolean useSessionKeys) { this.extractorFactory = extractorFactory; this.playlistTracker = playlistTracker; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index 4f6a0405f2..db52fa1c02 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -15,8 +15,11 @@ */ package com.google.android.exoplayer2.source.hls; +import static java.lang.annotation.RetentionPolicy.SOURCE; + import android.net.Uri; import android.os.Handler; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; @@ -47,6 +50,8 @@ import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; import java.util.List; /** An HLS {@link MediaSource}. */ @@ -57,6 +62,28 @@ public final class HlsMediaSource extends BaseMediaSource ExoPlayerLibraryInfo.registerModule("goog.exo.hls"); } + /** + * The types of metadata that can be extracted from HLS streams. + * + *

Allowed values: + * + *

    + *
  • {@link #METADATA_TYPE_ID3} + *
  • {@link #METADATA_TYPE_EMSG} + *
+ * + *

See {@link Factory#setMetadataType(int)}. + */ + @Documented + @Retention(SOURCE) + @IntDef({METADATA_TYPE_ID3, METADATA_TYPE_EMSG}) + public @interface MetadataType {} + + /** Type for ID3 metadata in HLS streams. */ + public static final int METADATA_TYPE_ID3 = 1; + /** Type for ESMG metadata in HLS streams. */ + public static final int METADATA_TYPE_EMSG = 3; + /** Factory for {@link HlsMediaSource}s. */ public static final class Factory implements MediaSourceFactory { @@ -70,7 +97,7 @@ public final class HlsMediaSource extends BaseMediaSource private DrmSessionManager drmSessionManager; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private boolean allowChunklessPreparation; - @HlsMetadataType private int metadataType; + @MetadataType private int metadataType; private boolean useSessionKeys; private boolean isCreateCalled; @Nullable private Object tag; @@ -100,7 +127,7 @@ public final class HlsMediaSource extends BaseMediaSource drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); - metadataType = HlsMetadataType.ID3; + metadataType = METADATA_TYPE_ID3; } /** @@ -246,24 +273,24 @@ public final class HlsMediaSource extends BaseMediaSource /** * Sets the type of metadata to extract from the HLS source (defaults to {@link - * HlsMetadataType#ID3}). + * #METADATA_TYPE_ID3}). * *

HLS supports in-band ID3 in both TS and fMP4 streams, but in the fMP4 case the data is * wrapped in an EMSG box [spec]. * - *

If this is set to {@link HlsMetadataType#ID3} then raw ID3 metadata of will be extracted + *

If this is set to {@link #METADATA_TYPE_ID3} then raw ID3 metadata of will be extracted * from TS sources. From fMP4 streams EMSGs containing metadata of this type (in the variant * stream only) will be unwrapped to expose the inner data. All other in-band metadata will be * dropped. * - *

If this is set to {@link HlsMetadataType#EMSG} then all EMSG data from the fMP4 variant + *

If this is set to {@link #METADATA_TYPE_EMSG} then all EMSG data from the fMP4 variant * stream will be extracted. No metadata will be extracted from TS streams, since they don't * support EMSG. * * @param metadataType The type of metadata to extract. * @return This factory, for convenience. */ - public Factory setMetadataType(@HlsMetadataType int metadataType) { + public Factory setMetadataType(@MetadataType int metadataType) { Assertions.checkState(!isCreateCalled); this.metadataType = metadataType; return this; @@ -347,7 +374,7 @@ public final class HlsMediaSource extends BaseMediaSource private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final boolean allowChunklessPreparation; - private final @HlsMetadataType int metadataType; + private final @MetadataType int metadataType; private final boolean useSessionKeys; private final HlsPlaylistTracker playlistTracker; @Nullable private final Object tag; @@ -363,7 +390,7 @@ public final class HlsMediaSource extends BaseMediaSource LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistTracker playlistTracker, boolean allowChunklessPreparation, - @HlsMetadataType int metadataType, + @MetadataType int metadataType, boolean useSessionKeys, @Nullable Object tag) { this.manifestUri = manifestUri; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMetadataType.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMetadataType.java deleted file mode 100644 index 8fb6c220cf..0000000000 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMetadataType.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.google.android.exoplayer2.source.hls; - -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import androidx.annotation.IntDef; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; - -/** - * The types of metadata that can be extracted from HLS streams. - * - *

See {@link HlsMediaSource.Factory#setMetadataType(int)}. - */ -@Documented -@Retention(SOURCE) -@IntDef({HlsMetadataType.ID3, HlsMetadataType.EMSG}) -public @interface HlsMetadataType { - /** Type for ID3 metadata in HLS streams. */ - int ID3 = 1; - /** Type for ESMG metadata in HLS streams. */ - int EMSG = 3; -} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 18465bcaf7..77242ea0fa 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -116,7 +116,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final Loader loader; private final EventDispatcher eventDispatcher; - private final @HlsMetadataType int metadataType; + private final @HlsMediaSource.MetadataType int metadataType; private final HlsChunkSource.HlsChunkHolder nextChunkHolder; private final ArrayList mediaChunks; private final List readOnlyMediaChunks; @@ -190,7 +190,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, EventDispatcher eventDispatcher, - @HlsMetadataType int metadataType) { + @HlsMediaSource.MetadataType int metadataType) { this.trackType = trackType; this.callback = callback; this.chunkSource = chunkSource; @@ -1362,14 +1362,15 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private byte[] buffer; private int bufferPosition; - public EmsgUnwrappingTrackOutput(TrackOutput delegate, @HlsMetadataType int metadataType) { + public EmsgUnwrappingTrackOutput( + TrackOutput delegate, @HlsMediaSource.MetadataType int metadataType) { this.emsgDecoder = new EventMessageDecoder(); this.delegate = delegate; switch (metadataType) { - case HlsMetadataType.ID3: + case HlsMediaSource.METADATA_TYPE_ID3: delegateFormat = ID3_FORMAT; break; - case HlsMetadataType.EMSG: + case HlsMediaSource.METADATA_TYPE_EMSG: delegateFormat = EMSG_FORMAT; break; default: diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java index 73ef11bda9..820c39c197 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java @@ -92,7 +92,7 @@ public final class HlsMediaPeriodTest { mock(Allocator.class), mock(CompositeSequenceableLoaderFactory.class), /* allowChunklessPreparation =*/ true, - HlsMetadataType.ID3, + HlsMediaSource.METADATA_TYPE_ID3, /* useSessionKeys= */ false); }; From 63f7b99836114078248b9129ae366c92781c4a75 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 19 Dec 2019 12:22:24 +0000 Subject: [PATCH 0018/1052] Bump to 2.11.1 PiperOrigin-RevId: 286368964 --- RELEASENOTES.md | 21 +++++++++++-------- constants.gradle | 4 ++-- .../exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 57eb4f9519..322afc5769 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,15 +4,18 @@ * UI: Exclude `DefaultTimeBar` region from system gesture detection ([#6685](https://github.com/google/ExoPlayer/issues/6685)). -* Propagate HTTP request headers through `CacheDataSource`. -* AV1 extension: Fix ProGuard rules - ([6773](https://github.com/google/ExoPlayer/issues/6773)). -* Suppress ProGuard warnings for compile-time `javax.annotation` package - ([#6771](https://github.com/google/ExoPlayer/issues/6771)). -* Fix proguard rules for R8 to ensure raw resources used with - `RawResourceDataSource` are kept. -* Fix proguard rules to keep `VideoDecoderOutputBuffer` for video decoder - extensions. +* ProGuard fixes: + * Ensure `Libgav1VideoRenderer` constructor is kept for use by + `DefaultRenderersFactory` + ([#6773](https://github.com/google/ExoPlayer/issues/6773)). + * Ensure `VideoDecoderOutputBuffer` and its members are kept for use by video + decoder extensions. + * Ensure raw resources used with `RawResourceDataSource` are kept. + * Suppress spurious warnings about the `javax.annotation` package, and + restructure use of `IntDef` annotations to remove spurious warnings about + `SsaStyle$SsaAlignment` + ([#6771](https://github.com/google/ExoPlayer/issues/6771)). +* Fix `CacheDataSource` to correctly propagate `DataSpec.httpRequestHeaders`. ### 2.11.0 (2019-12-11) ### diff --git a/constants.gradle b/constants.gradle index 599af54dde..392c623455 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.0' - releaseVersionCode = 2011000 + releaseVersion = '2.11.1' + releaseVersionCode = 2011001 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 249ef7e44e..217f580e7b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.0"; + public static final String VERSION = "2.11.1"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.0"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.1"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011000; + public static final int VERSION_INT = 2011001; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From b7bc1fee9f39362039efd3465a0e34a0920f1ebe Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 20 Dec 2019 10:23:27 +0000 Subject: [PATCH 0019/1052] Add missing @Nullable to MediaCodecAudioRenderer.getMediaClock Without this @Nullable, potential subclasses can't override the method to return null if they don't want to use the renderer as a media clock. Issue:#6792 PiperOrigin-RevId: 286545736 --- RELEASENOTES.md | 3 +++ .../android/exoplayer2/audio/MediaCodecAudioRenderer.java | 1 + .../android/exoplayer2/audio/SimpleDecoderAudioRenderer.java | 1 + 3 files changed, 5 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 322afc5769..074294ae0a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -16,6 +16,9 @@ `SsaStyle$SsaAlignment` ([#6771](https://github.com/google/ExoPlayer/issues/6771)). * Fix `CacheDataSource` to correctly propagate `DataSpec.httpRequestHeaders`. +* Add missing @Nullable to `MediaCodecAudioRenderer.getMediaClock` and + `SimpleDecoderAudioRenderer.getMediaClock` + ([#6792](https://github.com/google/ExoPlayer/issues/6792)). ### 2.11.0 (2019-12-11) ### diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index ae50d14728..096f4ccd1f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -520,6 +520,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @Override + @Nullable public MediaClock getMediaClock() { return this; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java index 5ccbf04c5c..60870204cc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java @@ -218,6 +218,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } @Override + @Nullable public MediaClock getMediaClock() { return this; } From e8cb7b237061b8e4ed4bd5cd2de020c4ddc882ce Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 12:14:29 +0000 Subject: [PATCH 0020/1052] Fix typo Merge of https://github.com/google/ExoPlayer/pull/6793 PiperOrigin-RevId: 286556008 --- .../google/android/exoplayer2/trackselection/TrackSelector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java index fb74bd9d54..c2fbeb6e2d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java @@ -75,7 +75,7 @@ import com.google.android.exoplayer2.util.Assertions; * the two are tightly bound together. It may only be possible to play a certain combination tracks * if the renderers are configured in a particular way. Equally, it may only be possible to * configure renderers in a particular way if certain tracks are selected. Hence it makes sense to - * determined the track selection and corresponding renderer configurations in a single step. + * determine the track selection and corresponding renderer configurations in a single step. * *

Threading model

* From 406acfc38fba096311678ba322fed244c0efb219 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 15:37:08 +0000 Subject: [PATCH 0021/1052] Relax MP4 sniffing to allow an atom to extend beyond the file length Issue: #6774 PiperOrigin-RevId: 286575797 --- .../com/google/android/exoplayer2/extractor/mp4/Sniffer.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java index 95193785c0..dac74bfe2b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java @@ -118,10 +118,6 @@ import java.io.IOException; } } - if (inputLength != C.LENGTH_UNSET && bytesSearched + atomSize > inputLength) { - // The file is invalid because the atom extends past the end of the file. - return false; - } if (atomSize < headerSize) { // The file is invalid because the atom size is too small for its header. return false; From 87865a5cc442f353fce1979a0047f726a0b8ffd6 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 15:41:01 +0000 Subject: [PATCH 0022/1052] DefaultDownloadIndex: Clear failure reason when removing download The Download constructor considers it invalid to have a failure reason if the download isn't in the failed state. Unfortunately, calling DefaultDownloadIndex.removeAllDownloads when there's a failed download will change the state without clearing the reason. If the downloads are then read back from the DefaultDownloadIndex we end up violating the Download constructor assertion. This change clears the failed reason for any existing rows in the invalid state, and also fixes the root cause that allows invalid rows to enter the table in the first place. Issue: #6785 PiperOrigin-RevId: 286576242 --- RELEASENOTES.md | 4 ++++ .../offline/DefaultDownloadIndex.java | 17 +++++++++++++++-- .../android/exoplayer2/offline/Download.java | 4 ++-- .../offline/DefaultDownloadIndexTest.java | 17 +++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 074294ae0a..3f97174278 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -16,6 +16,10 @@ `SsaStyle$SsaAlignment` ([#6771](https://github.com/google/ExoPlayer/issues/6771)). * Fix `CacheDataSource` to correctly propagate `DataSpec.httpRequestHeaders`. +* Fix issue with `DefaultDownloadIndex` that could result in an + `IllegalStateException` being thrown from + `DefaultDownloadIndex.getDownloadForCurrentRow` + ([#6785](https://github.com/google/ExoPlayer/issues/6785)). * Add missing @Nullable to `MediaCodecAudioRenderer.getMediaClock` and `SimpleDecoderAudioRenderer.getMediaClock` ([#6792](https://github.com/google/ExoPlayer/issues/6792)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java index 7ed1eb095f..f1c897813f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -26,6 +26,8 @@ import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.database.DatabaseIOException; import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.database.VersionTable; +import com.google.android.exoplayer2.offline.Download.FailureReason; +import com.google.android.exoplayer2.offline.Download.State; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; @@ -239,6 +241,9 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { try { ContentValues values = new ContentValues(); values.put(COLUMN_STATE, Download.STATE_REMOVING); + // Only downloads in STATE_FAILED are allowed a failure reason, so we need to clear it here in + // case we're moving downloads from STATE_FAILED to STATE_REMOVING. + values.put(COLUMN_FAILURE_REASON, Download.FAILURE_REASON_NONE); SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); writableDatabase.update(tableName, values, /* whereClause= */ null, /* whereArgs= */ null); } catch (SQLException e) { @@ -351,14 +356,22 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { DownloadProgress downloadProgress = new DownloadProgress(); downloadProgress.bytesDownloaded = cursor.getLong(COLUMN_INDEX_BYTES_DOWNLOADED); downloadProgress.percentDownloaded = cursor.getFloat(COLUMN_INDEX_PERCENT_DOWNLOADED); + @State int state = cursor.getInt(COLUMN_INDEX_STATE); + // It's possible the database contains failure reasons for non-failed downloads, which is + // invalid. Clear them here. See https://github.com/google/ExoPlayer/issues/6785. + @FailureReason + int failureReason = + state == Download.STATE_FAILED + ? cursor.getInt(COLUMN_INDEX_FAILURE_REASON) + : Download.FAILURE_REASON_NONE; return new Download( request, - /* state= */ cursor.getInt(COLUMN_INDEX_STATE), + state, /* startTimeMs= */ cursor.getLong(COLUMN_INDEX_START_TIME_MS), /* updateTimeMs= */ cursor.getLong(COLUMN_INDEX_UPDATE_TIME_MS), /* contentLength= */ cursor.getLong(COLUMN_INDEX_CONTENT_LENGTH), /* stopReason= */ cursor.getInt(COLUMN_INDEX_STOP_REASON), - /* failureReason= */ cursor.getInt(COLUMN_INDEX_FAILURE_REASON), + failureReason, downloadProgress); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/Download.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/Download.java index 97dff8394e..da46120b29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/Download.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/Download.java @@ -130,9 +130,9 @@ public final class Download { @FailureReason int failureReason, DownloadProgress progress) { Assertions.checkNotNull(progress); - Assertions.checkState((failureReason == FAILURE_REASON_NONE) == (state != STATE_FAILED)); + Assertions.checkArgument((failureReason == FAILURE_REASON_NONE) == (state != STATE_FAILED)); if (stopReason != 0) { - Assertions.checkState(state != STATE_DOWNLOADING && state != STATE_QUEUED); + Assertions.checkArgument(state != STATE_DOWNLOADING && state != STATE_QUEUED); } this.request = request; this.state = state; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java index f42a1c6086..d7664e21ca 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java @@ -248,6 +248,23 @@ public class DefaultDownloadIndexTest { assertEqual(readDownload, download); } + @Test + public void setStatesToRemoving_setsStateAndClearsFailureReason() throws Exception { + String id = "id"; + DownloadBuilder downloadBuilder = + new DownloadBuilder(id) + .setState(Download.STATE_FAILED) + .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + Download download = downloadBuilder.build(); + downloadIndex.putDownload(download); + + downloadIndex.setStatesToRemoving(); + + download = downloadIndex.getDownload(id); + assertThat(download.state).isEqualTo(Download.STATE_REMOVING); + assertThat(download.failureReason).isEqualTo(Download.FAILURE_REASON_NONE); + } + @Test public void setSingleDownloadStopReason_setReasonToNone() throws Exception { String id = "id"; From c6036d5271fad5e6e96db205fa001acd4a735587 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 15:42:10 +0000 Subject: [PATCH 0023/1052] Add test case for mdat atom extending beyond the file length Issue: #6774 PiperOrigin-RevId: 286576383 --- .../test/assets/mp4/sample_mdat_too_long.mp4 | Bin 0 -> 101597 bytes .../mp4/sample_mdat_too_long.mp4.0.dump | 359 ++++++++++++++++++ .../mp4/sample_mdat_too_long.mp4.1.dump | 311 +++++++++++++++ .../mp4/sample_mdat_too_long.mp4.2.dump | 251 ++++++++++++ .../mp4/sample_mdat_too_long.mp4.3.dump | 191 ++++++++++ .../extractor/mp4/Mp4ExtractorTest.java | 9 + 6 files changed, 1121 insertions(+) create mode 100644 library/core/src/test/assets/mp4/sample_mdat_too_long.mp4 create mode 100644 library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.0.dump create mode 100644 library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.1.dump create mode 100644 library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.2.dump create mode 100644 library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.3.dump diff --git a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4 b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f50d4f49de53c19d49856c047580278c9a5e80ec GIT binary patch literal 101597 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 Date: Fri, 20 Dec 2019 16:38:34 +0000 Subject: [PATCH 0024/1052] Fix potentially wrong window index in onMediaPeriodCreated. Issue:#6776 --- .../android/exoplayer2/analytics/AnalyticsCollector.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 5a57844c83..b215fa5211 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -806,7 +806,10 @@ public class AnalyticsCollector public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { boolean isInTimeline = timeline.getIndexOfPeriod(mediaPeriodId.periodUid) != C.INDEX_UNSET; MediaPeriodInfo mediaPeriodInfo = - new MediaPeriodInfo(mediaPeriodId, isInTimeline ? timeline : Timeline.EMPTY, windowIndex); + new MediaPeriodInfo( + mediaPeriodId, + isInTimeline ? timeline : Timeline.EMPTY, + isInTimeline ? timeline.getPeriod(periodIndex, period).windowIndex : windowIndex); mediaPeriodInfoQueue.add(mediaPeriodInfo); mediaPeriodIdToInfo.put(mediaPeriodId, mediaPeriodInfo); lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); From c2997481322001e7700a9fe97a379b7ab6da658b Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 20 Dec 2019 16:47:42 +0000 Subject: [PATCH 0025/1052] Fix build by saving periodIndex to separate variable --- .../android/exoplayer2/analytics/AnalyticsCollector.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index b215fa5211..dc1089eca0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -804,7 +804,8 @@ public class AnalyticsCollector /** Updates the queue with a newly created media period. */ public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { - boolean isInTimeline = timeline.getIndexOfPeriod(mediaPeriodId.periodUid) != C.INDEX_UNSET; + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + boolean isInTimeline = periodIndex != C.INDEX_UNSET; MediaPeriodInfo mediaPeriodInfo = new MediaPeriodInfo( mediaPeriodId, From 72ff4504d360915101c3908577ecff4424262f9b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 20 Dec 2019 16:51:02 +0000 Subject: [PATCH 0026/1052] Add troubleshooting instructions for decoding extensions PiperOrigin-RevId: 286585978 --- extensions/av1/README.md | 8 ++++++++ extensions/ffmpeg/README.md | 10 ++++++++++ extensions/flac/README.md | 8 ++++++++ extensions/opus/README.md | 8 ++++++++ extensions/vp9/README.md | 8 ++++++++ 5 files changed, 42 insertions(+) diff --git a/extensions/av1/README.md b/extensions/av1/README.md index 276daae4e2..54e27a3b87 100644 --- a/extensions/av1/README.md +++ b/extensions/av1/README.md @@ -96,6 +96,14 @@ a custom track selector the choice of `Renderer` is up to your implementation. You need to make sure you are passing a `Libgav1VideoRenderer` to the player and then you need to implement your own logic to use the renderer for a given track. +## Using the extension in the demo application ## + +To try out playback using the extension in the [demo application][], see +[enabling extension decoders][]. + +[demo application]: https://exoplayer.dev/demo-application.html +[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders + ## Rendering options ## There are two possibilities for rendering the output `Libgav1VideoRenderer` diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md index fe4aca772a..1b2db8f0f4 100644 --- a/extensions/ffmpeg/README.md +++ b/extensions/ffmpeg/README.md @@ -106,9 +106,19 @@ then implement your own logic to use the renderer for a given track. [#2781]: https://github.com/google/ExoPlayer/issues/2781 [Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension +## Using the extension in the demo application ## + +To try out playback using the extension in the [demo application][], see +[enabling extension decoders][]. + +[demo application]: https://exoplayer.dev/demo-application.html +[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders + ## Links ## +* [Troubleshooting using extensions][] * [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.ffmpeg.*` belong to this module. +[Troubleshooting using extensions]: https://exoplayer.dev/troubleshooting.html#how-can-i-get-a-decoding-extension-to-load-and-be-used-for-playback [Javadoc]: https://exoplayer.dev/doc/reference/index.html diff --git a/extensions/flac/README.md b/extensions/flac/README.md index 84a92f9586..a9d4c3094e 100644 --- a/extensions/flac/README.md +++ b/extensions/flac/README.md @@ -97,6 +97,14 @@ a custom track selector the choice of `Renderer` is up to your implementation, so you need to make sure you are passing an `LibflacAudioRenderer` to the player, then implement your own logic to use the renderer for a given track. +## Using the extension in the demo application ## + +To try out playback using the extension in the [demo application][], see +[enabling extension decoders][]. + +[demo application]: https://exoplayer.dev/demo-application.html +[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders + ## Links ## * [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.flac.*` diff --git a/extensions/opus/README.md b/extensions/opus/README.md index 05448f2073..d3691b07bd 100644 --- a/extensions/opus/README.md +++ b/extensions/opus/README.md @@ -101,6 +101,14 @@ a custom track selector the choice of `Renderer` is up to your implementation, so you need to make sure you are passing an `LibopusAudioRenderer` to the player, then implement your own logic to use the renderer for a given track. +## Using the extension in the demo application ## + +To try out playback using the extension in the [demo application][], see +[enabling extension decoders][]. + +[demo application]: https://exoplayer.dev/demo-application.html +[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders + ## Links ## * [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.opus.*` diff --git a/extensions/vp9/README.md b/extensions/vp9/README.md index 71241d9a4f..fd0836648a 100644 --- a/extensions/vp9/README.md +++ b/extensions/vp9/README.md @@ -114,6 +114,14 @@ a custom track selector the choice of `Renderer` is up to your implementation, so you need to make sure you are passing an `LibvpxVideoRenderer` to the player, then implement your own logic to use the renderer for a given track. +## Using the extension in the demo application ## + +To try out playback using the extension in the [demo application][], see +[enabling extension decoders][]. + +[demo application]: https://exoplayer.dev/demo-application.html +[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders + ## Rendering options ## There are two possibilities for rendering the output `LibvpxVideoRenderer` From 472a4d2f5b5c32bf8fa969608f9f7af8967469ae Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 16:57:42 +0000 Subject: [PATCH 0027/1052] Update release notes for #6776 PiperOrigin-RevId: 286586865 --- RELEASENOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3f97174278..c219ad813b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -20,6 +20,8 @@ `IllegalStateException` being thrown from `DefaultDownloadIndex.getDownloadForCurrentRow` ([#6785](https://github.com/google/ExoPlayer/issues/6785)). +* Fix `IndexOutOfBoundsException` in `SinglePeriodTimeline.getWindow` + ([#6776](https://github.com/google/ExoPlayer/issues/6776)). * Add missing @Nullable to `MediaCodecAudioRenderer.getMediaClock` and `SimpleDecoderAudioRenderer.getMediaClock` ([#6792](https://github.com/google/ExoPlayer/issues/6792)). From f6dad5cee0d0b21cda8d67ccf036579c642ccfb7 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 20:15:27 +0000 Subject: [PATCH 0028/1052] Enable blacklisting for HTTP 416 Where media segments are specified using byte ranges, it makes sense that a server might return 416 (which we don't consider for blacklisting) if the segment is unavailable, rather than the 404 (which we do consider for blacklisting) that we expect when media segments are only specified using a URL. Issue: #6775 PiperOrigin-RevId: 286620698 --- .../exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java | 1 + 1 file changed, 1 insertion(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java index 307652f456..435f4bf578 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java @@ -71,6 +71,7 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { int responseCode = ((InvalidResponseCodeException) exception).responseCode; return responseCode == 404 // HTTP 404 Not Found. || responseCode == 410 // HTTP 410 Gone. + || responseCode == 416 // HTTP 416 Range Not Satisfiable. ? DEFAULT_TRACK_BLACKLIST_MS : C.TIME_UNSET; } From 924045be034c35d7378e6bdb185885e698d5b2f5 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 20 Dec 2019 17:04:06 +0000 Subject: [PATCH 0029/1052] Release notes tweak PiperOrigin-RevId: 286587978 --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c219ad813b..49127a1099 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,7 +22,7 @@ ([#6785](https://github.com/google/ExoPlayer/issues/6785)). * Fix `IndexOutOfBoundsException` in `SinglePeriodTimeline.getWindow` ([#6776](https://github.com/google/ExoPlayer/issues/6776)). -* Add missing @Nullable to `MediaCodecAudioRenderer.getMediaClock` and +* Add missing `@Nullable` to `MediaCodecAudioRenderer.getMediaClock` and `SimpleDecoderAudioRenderer.getMediaClock` ([#6792](https://github.com/google/ExoPlayer/issues/6792)). From 27e48558304cdb672edf05383171215d32c255cb Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 20 Dec 2019 22:18:52 +0000 Subject: [PATCH 0030/1052] Fix tests for 2.11.1 --- .../core/src/test/assets/mp4/sample_mdat_too_long.mp4.0.dump | 2 -- .../core/src/test/assets/mp4/sample_mdat_too_long.mp4.1.dump | 2 -- .../core/src/test/assets/mp4/sample_mdat_too_long.mp4.2.dump | 2 -- .../core/src/test/assets/mp4/sample_mdat_too_long.mp4.3.dump | 2 -- 4 files changed, 8 deletions(-) diff --git a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.0.dump b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.0.dump index 3b1c90c783..4bc30a8bef 100644 --- a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.0.dump @@ -24,7 +24,6 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -171,7 +170,6 @@ track 1: selectionFlags = 0 language = und drmInitData = - - metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 9529 diff --git a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.1.dump b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.1.dump index 7d8c3c1e5d..3b4906f063 100644 --- a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.1.dump @@ -24,7 +24,6 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -171,7 +170,6 @@ track 1: selectionFlags = 0 language = und drmInitData = - - metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 7464 diff --git a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.2.dump b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.2.dump index a87f1678a4..b4db32c20c 100644 --- a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.2.dump @@ -24,7 +24,6 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -171,7 +170,6 @@ track 1: selectionFlags = 0 language = und drmInitData = - - metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 4019 diff --git a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.3.dump b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.3.dump index 6431ca98c6..fd58d5042e 100644 --- a/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample_mdat_too_long.mp4.3.dump @@ -24,7 +24,6 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -171,7 +170,6 @@ track 1: selectionFlags = 0 language = und drmInitData = - - metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 470 From a0eb081aed0b73fedc687e06c8d122baedc40789 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 10 Jan 2020 10:24:22 +0000 Subject: [PATCH 0031/1052] Add favicon to javadocs --- javadoc_util.gradle | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/javadoc_util.gradle b/javadoc_util.gradle index cff5f29392..d5b1f56720 100644 --- a/javadoc_util.gradle +++ b/javadoc_util.gradle @@ -27,6 +27,23 @@ ext.fixJavadoc = { ant.replaceregexp(match:oracleLink, replace:oracleFixed, flags:'g') { fileset(dir: "${javadocPath}", includes: "**/*.html") } + // Add favicon to each page + def headTag = "" + def headTagWithFavicon = "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + ant.replaceregexp(match:headTag, replace:headTagWithFavicon, flags:'g') { + fileset(dir: "${javadocPath}", includes: "**/*.html") + } // Remove date metadata that changes every time Javadoc is generated. def javadocGeneratedBy = "\n" ant.replaceregexp(match:javadocGeneratedBy, replace:"") { From dcebf93ab4f7b8746e2a1025f8dee93bcf0e303e Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 18 Nov 2019 17:16:45 +0000 Subject: [PATCH 0032/1052] Add Java FLAC extractor Seeking, live streams support and exposure of vorbis and ID3 data are not part of this commit. Issue: #6406 PiperOrigin-RevId: 281083332 --- RELEASENOTES.md | 8 + demos/main/src/main/assets/media.exolist.json | 4 + .../ext/flac/FlacBinarySearchSeeker.java | 2 +- .../exoplayer2/ext/flac/FlacDecoder.java | 2 +- .../exoplayer2/ext/flac/FlacExtractor.java | 33 +- .../extractor/DefaultExtractorsFactory.java | 19 +- .../extractor/flac/FlacExtractor.java | 302 ++++++++++++++++++ .../exoplayer2/extractor/ogg/FlacReader.java | 24 +- .../exoplayer2/util/FlacConstants.java | 33 ++ .../exoplayer2/util/FlacFrameReader.java | 257 +++++++++++++++ .../exoplayer2/util/FlacMetadataReader.java | 208 ++++++++++++ .../exoplayer2/util/FlacStreamMetadata.java | 163 ++++++++-- library/core/src/test/assets/flac/bear.flac | Bin 0 -> 173311 bytes .../src/test/assets/flac/bear.flac.0.dump | 163 ++++++++++ .../flac/bear_no_min_max_frame_size.flac | Bin 0 -> 173311 bytes .../bear_no_min_max_frame_size.flac.0.dump | 163 ++++++++++ .../test/assets/flac/bear_no_num_samples.flac | Bin 0 -> 173311 bytes .../flac/bear_no_num_samples.flac.0.dump | 163 ++++++++++ .../assets/flac/bear_one_metadata_block.flac | Bin 0 -> 164473 bytes .../flac/bear_one_metadata_block.flac.0.dump | 163 ++++++++++ .../flac/bear_uncommon_sample_rate.flac | Bin 0 -> 152374 bytes .../bear_uncommon_sample_rate.flac.0.dump | 139 ++++++++ .../src/test/assets/flac/bear_with_id3.flac | Bin 0 -> 219715 bytes .../assets/flac/bear_with_id3.flac.0.dump | 163 ++++++++++ .../DefaultExtractorsFactoryTest.java | 4 +- .../extractor/flac/FlacExtractorTest.java | 56 ++++ 26 files changed, 1989 insertions(+), 80 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/FlacConstants.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java create mode 100644 library/core/src/test/assets/flac/bear.flac create mode 100644 library/core/src/test/assets/flac/bear.flac.0.dump create mode 100644 library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac create mode 100644 library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump create mode 100644 library/core/src/test/assets/flac/bear_no_num_samples.flac create mode 100644 library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump create mode 100644 library/core/src/test/assets/flac/bear_one_metadata_block.flac create mode 100644 library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump create mode 100644 library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac create mode 100644 library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump create mode 100644 library/core/src/test/assets/flac/bear_with_id3.flac create mode 100644 library/core/src/test/assets/flac/bear_with_id3.flac.0.dump create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 49127a1099..a1b79518a9 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,13 @@ # Release notes # +### 2.11.2 (TBD) ### + +* Add Java FLAC extractor + ([#6406](https://github.com/google/ExoPlayer/issues/6406)). + This extractor does not support seeking and live streams, and does not expose + vorbis, ID3 and picture data. If `DefaultExtractorsFactory` is used, this + extractor is only used if the FLAC extension is not loaded. + ### 2.11.1 (2019-12-20) ### * UI: Exclude `DefaultTimeBar` region from system gesture detection diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 01980c2f36..8550377ddf 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -411,6 +411,10 @@ "name": "Google Play (Ogg/Vorbis)", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/ogg/play.ogg" }, + { + "name": "Google Play (FLAC)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/flac/play.flac" + }, { "name": "Big Buck Bunny video (FLV)", "uri": "https://vod.leasewebcdn.com/bbb.flv?ri=1024&rs=150&start=0" diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java index 4bfcc003ec..08f179152e 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java @@ -41,7 +41,7 @@ import java.nio.ByteBuffer; super( new FlacSeekTimestampConverter(streamMetadata), new FlacTimestampSeeker(decoderJni), - streamMetadata.durationUs(), + streamMetadata.getDurationUs(), /* floorTimePosition= */ 0, /* ceilingTimePosition= */ streamMetadata.totalSamples, /* floorBytePosition= */ firstFramePosition, diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java index 890d82a006..e1f6112319 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java @@ -72,7 +72,7 @@ import java.util.List; int initialInputBufferSize = maxInputBufferSize != Format.NO_VALUE ? maxInputBufferSize : streamMetadata.maxFrameSize; setInitialInputBufferSize(initialInputBufferSize); - maxOutputBufferSize = streamMetadata.maxDecodedFrameSize(); + maxOutputBufferSize = streamMetadata.getMaxDecodedFrameSize(); } @Override diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index fb5d41c0de..02a57dbf81 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -34,6 +34,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.FlacMetadataReader; import com.google.android.exoplayer2.util.FlacStreamMetadata; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -42,7 +43,6 @@ import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; -import java.util.Arrays; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -72,9 +72,6 @@ public final class FlacExtractor implements Extractor { */ public static final int FLAG_DISABLE_ID3_METADATA = 1; - /** FLAC stream marker */ - private static final byte[] FLAC_STREAM_MARKER = {'f', 'L', 'a', 'C'}; - private final ParsableByteArray outputBuffer; private final Id3Peeker id3Peeker; private final boolean id3MetadataDisabled; @@ -120,10 +117,8 @@ public final class FlacExtractor implements Extractor { @Override public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { - if (input.getPosition() == 0) { - id3Metadata = peekId3Data(input); - } - return peekFlacStreamMarker(input); + id3Metadata = peekId3Data(input); + return FlacMetadataReader.checkAndPeekStreamMarker(input); } @Override @@ -230,7 +225,7 @@ public final class FlacExtractor implements Extractor { metadata = streamMetadata.metadata.copyWithAppendedEntriesFrom(metadata); } outputFormat(streamMetadata, metadata, trackOutput); - outputBuffer.reset(streamMetadata.maxDecodedFrameSize()); + outputBuffer.reset(streamMetadata.getMaxDecodedFrameSize()); outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.data)); } } @@ -251,18 +246,6 @@ public final class FlacExtractor implements Extractor { return seekResult; } - /** - * Peeks from the beginning of the input to see if {@link #FLAC_STREAM_MARKER} is present. - * - * @return Whether the input begins with {@link #FLAC_STREAM_MARKER}. - */ - private static boolean peekFlacStreamMarker(ExtractorInput input) - throws IOException, InterruptedException { - byte[] header = new byte[FLAC_STREAM_MARKER.length]; - input.peekFully(header, /* offset= */ 0, FLAC_STREAM_MARKER.length); - return Arrays.equals(header, FLAC_STREAM_MARKER); - } - /** * Outputs a {@link SeekMap} and returns a {@link FlacBinarySearchSeeker} if one is required to * handle seeks. @@ -277,14 +260,14 @@ public final class FlacExtractor implements Extractor { FlacBinarySearchSeeker binarySearchSeeker = null; SeekMap seekMap; if (haveSeekTable) { - seekMap = new FlacSeekMap(streamMetadata.durationUs(), decoderJni); + seekMap = new FlacSeekMap(streamMetadata.getDurationUs(), decoderJni); } else if (streamLength != C.LENGTH_UNSET) { long firstFramePosition = decoderJni.getDecodePosition(); binarySearchSeeker = new FlacBinarySearchSeeker(streamMetadata, firstFramePosition, streamLength, decoderJni); seekMap = binarySearchSeeker.getSeekMap(); } else { - seekMap = new SeekMap.Unseekable(streamMetadata.durationUs()); + seekMap = new SeekMap.Unseekable(streamMetadata.getDurationUs()); } output.seekMap(seekMap); return binarySearchSeeker; @@ -297,8 +280,8 @@ public final class FlacExtractor implements Extractor { /* id= */ null, MimeTypes.AUDIO_RAW, /* codecs= */ null, - streamMetadata.bitRate(), - streamMetadata.maxDecodedFrameSize(), + streamMetadata.getBitRate(), + streamMetadata.getMaxDecodedFrameSize(), streamMetadata.channels, streamMetadata.sampleRate, getPcmEncoding(streamMetadata.bitsPerSample), diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 02c676dfdf..26f250feea 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.extractor; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; +import com.google.android.exoplayer2.extractor.flac.FlacExtractor; import com.google.android.exoplayer2.extractor.flv.FlvExtractor; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; @@ -55,12 +56,13 @@ import java.lang.reflect.Constructor; */ public final class DefaultExtractorsFactory implements ExtractorsFactory { - private static final Constructor FLAC_EXTRACTOR_CONSTRUCTOR; + private static final Constructor FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR; + static { - Constructor flacExtractorConstructor = null; + Constructor flacExtensionExtractorConstructor = null; try { // LINT.IfChange - flacExtractorConstructor = + flacExtensionExtractorConstructor = Class.forName("com.google.android.exoplayer2.ext.flac.FlacExtractor") .asSubclass(Extractor.class) .getConstructor(); @@ -71,7 +73,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { // The FLAC extension is present, but instantiation failed. throw new RuntimeException("Error instantiating FLAC extension", e); } - FLAC_EXTRACTOR_CONSTRUCTOR = flacExtractorConstructor; + FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR = flacExtensionExtractorConstructor; } private boolean constantBitrateSeekingEnabled; @@ -208,7 +210,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { @Override public synchronized Extractor[] createExtractors() { - Extractor[] extractors = new Extractor[FLAC_EXTRACTOR_CONSTRUCTOR == null ? 13 : 14]; + Extractor[] extractors = new Extractor[14]; extractors[0] = new MatroskaExtractor(matroskaFlags); extractors[1] = new FragmentedMp4Extractor(fragmentedMp4Flags); extractors[2] = new Mp4Extractor(mp4Flags); @@ -237,13 +239,16 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING : 0)); extractors[12] = new Ac4Extractor(); - if (FLAC_EXTRACTOR_CONSTRUCTOR != null) { + // Prefer the FLAC extension extractor because it supports seeking. + if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { try { - extractors[13] = FLAC_EXTRACTOR_CONSTRUCTOR.newInstance(); + extractors[13] = FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(); } catch (Exception e) { // Should never happen. throw new IllegalStateException("Unexpected error creating FLAC extractor", e); } + } else { + extractors[13] = new FlacExtractor(); } return extractors; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java new file mode 100644 index 0000000000..33f608788b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.flac; + +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.IntDef; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.FlacConstants; +import com.google.android.exoplayer2.util.FlacFrameReader; +import com.google.android.exoplayer2.util.FlacFrameReader.BlockSizeHolder; +import com.google.android.exoplayer2.util.FlacMetadataReader; +import com.google.android.exoplayer2.util.FlacMetadataReader.FirstFrameMetadata; +import com.google.android.exoplayer2.util.FlacStreamMetadata; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +// TODO: implement seeking. +// TODO: expose vorbis and ID3 data. +// TODO: support live streams. +/** + * Extracts data from FLAC container format. + * + *

The format specification can be found at https://xiph.org/flac/format.html. + */ +public final class FlacExtractor implements Extractor { + + /** Factory for {@link FlacExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + + /** Parser state. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_READ_ID3_TAG, + STATE_READ_STREAM_MARKER, + STATE_READ_STREAM_INFO_BLOCK, + STATE_SKIP_OPTIONAL_METADATA_BLOCKS, + STATE_GET_FIRST_FRAME_METADATA, + STATE_READ_FRAMES + }) + private @interface State {} + + private static final int STATE_READ_ID3_TAG = 0; + private static final int STATE_READ_STREAM_MARKER = 1; + private static final int STATE_READ_STREAM_INFO_BLOCK = 2; + private static final int STATE_SKIP_OPTIONAL_METADATA_BLOCKS = 3; + private static final int STATE_GET_FIRST_FRAME_METADATA = 4; + private static final int STATE_READ_FRAMES = 5; + + /** Arbitrary scratch length of 32KB, which is ~170ms of 16-bit stereo PCM audio at 48KHz. */ + private static final int SCRATCH_LENGTH = 32 * 1024; + + /** Value of an unknown block size. */ + private static final int BLOCK_SIZE_UNKNOWN = -1; + + private final byte[] streamMarkerAndInfoBlock; + private final ParsableByteArray scratch; + + private final BlockSizeHolder blockSizeHolder; + + @MonotonicNonNull private ExtractorOutput extractorOutput; + @MonotonicNonNull private TrackOutput trackOutput; + + private @State int state; + @MonotonicNonNull private FlacStreamMetadata flacStreamMetadata; + private int minFrameSize; + private int frameStartMarker; + private int currentFrameBlockSizeSamples; + private int currentFrameBytesWritten; + private long totalSamplesWritten; + + public FlacExtractor() { + streamMarkerAndInfoBlock = + new byte[FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE]; + scratch = new ParsableByteArray(SCRATCH_LENGTH); + blockSizeHolder = new BlockSizeHolder(); + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + FlacMetadataReader.peekId3Data(input); + return FlacMetadataReader.checkAndPeekStreamMarker(input); + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + trackOutput = output.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + output.endTracks(); + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + switch (state) { + case STATE_READ_ID3_TAG: + readId3Tag(input); + return Extractor.RESULT_CONTINUE; + case STATE_READ_STREAM_MARKER: + readStreamMarker(input); + return Extractor.RESULT_CONTINUE; + case STATE_READ_STREAM_INFO_BLOCK: + readStreamInfoBlock(input); + return Extractor.RESULT_CONTINUE; + case STATE_SKIP_OPTIONAL_METADATA_BLOCKS: + skipOptionalMetadataBlocks(input); + return Extractor.RESULT_CONTINUE; + case STATE_GET_FIRST_FRAME_METADATA: + getFirstFrameMetadata(input); + return Extractor.RESULT_CONTINUE; + case STATE_READ_FRAMES: + return readFrames(input); + default: + throw new IllegalStateException(); + } + } + + @Override + public void seek(long position, long timeUs) { + state = STATE_READ_ID3_TAG; + currentFrameBytesWritten = 0; + totalSamplesWritten = 0; + scratch.reset(); + } + + @Override + public void release() { + // Do nothing. + } + + // Private methods. + + private void readId3Tag(ExtractorInput input) throws IOException, InterruptedException { + FlacMetadataReader.readId3Data(input); + state = STATE_READ_STREAM_MARKER; + } + + private void readStreamMarker(ExtractorInput input) throws IOException, InterruptedException { + FlacMetadataReader.readStreamMarker( + input, streamMarkerAndInfoBlock, /* scratchWriteIndex= */ 0); + state = STATE_READ_STREAM_INFO_BLOCK; + } + + private void readStreamInfoBlock(ExtractorInput input) throws IOException, InterruptedException { + flacStreamMetadata = + FlacMetadataReader.readStreamInfoBlock( + input, + /* scratchData= */ streamMarkerAndInfoBlock, + /* scratchWriteIndex= */ FlacConstants.STREAM_MARKER_SIZE); + minFrameSize = Math.max(flacStreamMetadata.minFrameSize, FlacConstants.MIN_FRAME_HEADER_SIZE); + boolean isLastMetadataBlock = + (streamMarkerAndInfoBlock[FlacConstants.STREAM_MARKER_SIZE] >> 7 & 1) == 1; + castNonNull(trackOutput).format(flacStreamMetadata.getFormat(streamMarkerAndInfoBlock)); + castNonNull(extractorOutput) + .seekMap(new SeekMap.Unseekable(flacStreamMetadata.getDurationUs())); + + if (isLastMetadataBlock) { + state = STATE_GET_FIRST_FRAME_METADATA; + } else { + state = STATE_SKIP_OPTIONAL_METADATA_BLOCKS; + } + } + + private void skipOptionalMetadataBlocks(ExtractorInput input) + throws IOException, InterruptedException { + FlacMetadataReader.skipMetadataBlocks(input); + state = STATE_GET_FIRST_FRAME_METADATA; + } + + private void getFirstFrameMetadata(ExtractorInput input) + throws IOException, InterruptedException { + FirstFrameMetadata firstFrameMetadata = FlacMetadataReader.getFirstFrameMetadata(input); + frameStartMarker = firstFrameMetadata.frameStartMarker; + currentFrameBlockSizeSamples = firstFrameMetadata.blockSizeSamples; + + state = STATE_READ_FRAMES; + } + + // TODO: consider sending bytes within min frame size directly from the input to the sample queue + // to avoid unnecessary copies in scratch. + private int readFrames(ExtractorInput input) throws IOException, InterruptedException { + Assertions.checkNotNull(trackOutput); + Assertions.checkNotNull(flacStreamMetadata); + + // Copy more bytes into the scratch. + int currentLimit = scratch.limit(); + int bytesRead = + input.read( + scratch.data, /* offset= */ currentLimit, /* length= */ SCRATCH_LENGTH - currentLimit); + boolean foundEndOfInput = bytesRead == C.RESULT_END_OF_INPUT; + if (!foundEndOfInput) { + scratch.setLimit(currentLimit + bytesRead); + } else if (scratch.bytesLeft() == 0) { + return C.RESULT_END_OF_INPUT; + } + + // Search for a frame. + int positionBeforeFindingAFrame = scratch.getPosition(); + + // Skip frame search on the bytes within the minimum frame size. + if (currentFrameBytesWritten < minFrameSize) { + scratch.skipBytes(Math.min(minFrameSize, scratch.bytesLeft())); + } + + int nextFrameBlockSizeSamples = findFrame(scratch, foundEndOfInput); + int numberOfFrameBytes = scratch.getPosition() - positionBeforeFindingAFrame; + scratch.setPosition(positionBeforeFindingAFrame); + trackOutput.sampleData(scratch, numberOfFrameBytes); + currentFrameBytesWritten += numberOfFrameBytes; + + // Frame found. + if (nextFrameBlockSizeSamples != BLOCK_SIZE_UNKNOWN || foundEndOfInput) { + long timeUs = getTimeUs(totalSamplesWritten, flacStreamMetadata.sampleRate); + trackOutput.sampleMetadata( + timeUs, + C.BUFFER_FLAG_KEY_FRAME, + currentFrameBytesWritten, + /* offset= */ 0, + /* encryptionData= */ null); + totalSamplesWritten += currentFrameBlockSizeSamples; + currentFrameBytesWritten = 0; + currentFrameBlockSizeSamples = nextFrameBlockSizeSamples; + } + + if (scratch.bytesLeft() < FlacConstants.MAX_FRAME_HEADER_SIZE) { + // The next frame header may not fit in the rest of the scratch, so put the trailing bytes at + // the start of the scratch, and reset the position and limit. + System.arraycopy( + scratch.data, scratch.getPosition(), scratch.data, /* destPos= */ 0, scratch.bytesLeft()); + scratch.reset(scratch.bytesLeft()); + } + + return Extractor.RESULT_CONTINUE; + } + + /** + * Searches for the start of a frame in {@code scratch}. + * + *

    + *
  • If the search is successful, the position is set to the start of the found frame. + *
  • Otherwise, the position is set to the first unsearched byte. + *
+ * + * @param scratch The array to be searched. + * @param foundEndOfInput If the end of input was met when filling in the {@code scratch}. + * @return The block size of the frame found, or {@code BLOCK_SIZE_UNKNOWN} if the search was not + * successful. + */ + private int findFrame(ParsableByteArray scratch, boolean foundEndOfInput) { + Assertions.checkNotNull(flacStreamMetadata); + + int frameOffset = scratch.getPosition(); + while (frameOffset <= scratch.limit() - FlacConstants.MAX_FRAME_HEADER_SIZE) { + scratch.setPosition(frameOffset); + if (FlacFrameReader.checkAndReadFrameHeader( + scratch, flacStreamMetadata, frameStartMarker, blockSizeHolder)) { + scratch.setPosition(frameOffset); + return blockSizeHolder.blockSizeSamples; + } + frameOffset++; + } + + if (foundEndOfInput) { + // Reached the end of the file. Assume it's the end of the frame. + scratch.setPosition(scratch.limit()); + } else { + scratch.setPosition(frameOffset); + } + + return BLOCK_SIZE_UNKNOWN; + } + + private long getTimeUs(long numSamples, int sampleRate) { + return numSamples * C.MICROS_PER_SECOND / sampleRate; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java index cef274b903..ed86944f1e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java @@ -15,18 +15,14 @@ */ package com.google.android.exoplayer2.extractor.ogg; -import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekPoint; import com.google.android.exoplayer2.util.FlacStreamMetadata; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.Arrays; -import java.util.Collections; -import java.util.List; /** * {@link StreamReader} to extract Flac data out of Ogg byte stream. @@ -72,24 +68,8 @@ import java.util.List; byte[] data = packet.data; if (streamMetadata == null) { streamMetadata = new FlacStreamMetadata(data, 17); - int maxInputSize = - streamMetadata.maxFrameSize == 0 ? Format.NO_VALUE : streamMetadata.maxFrameSize; byte[] metadata = Arrays.copyOfRange(data, 9, packet.limit()); - metadata[4] = (byte) 0x80; // Set the last metadata block flag, ignore the other blocks - List initializationData = Collections.singletonList(metadata); - setupData.format = - Format.createAudioSampleFormat( - /* id= */ null, - MimeTypes.AUDIO_FLAC, - /* codecs= */ null, - streamMetadata.bitRate(), - maxInputSize, - streamMetadata.channels, - streamMetadata.sampleRate, - initializationData, - /* drmInitData= */ null, - /* selectionFlags= */ 0, - /* language= */ null); + setupData.format = streamMetadata.getFormat(metadata); } else if ((data[0] & 0x7F) == SEEKTABLE_PACKET_TYPE) { flacOggSeeker = new FlacOggSeeker(); flacOggSeeker.parseSeekTable(packet); @@ -220,7 +200,7 @@ import java.util.List; @Override public long getDurationUs() { - return streamMetadata.durationUs(); + return streamMetadata.getDurationUs(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacConstants.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacConstants.java new file mode 100644 index 0000000000..75b153d6f9 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacConstants.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +/** Defines constants used by the FLAC extractor. */ +public final class FlacConstants { + + /** Size of the FLAC stream marker in bytes. */ + public static final int STREAM_MARKER_SIZE = 4; + /** Size of the header of a FLAC metadata block in bytes. */ + public static final int METADATA_BLOCK_HEADER_SIZE = 4; + /** Size of the FLAC stream info block (header included) in bytes. */ + public static final int STREAM_INFO_BLOCK_SIZE = 38; + /** Minimum size of a FLAC frame header in bytes. */ + public static final int MIN_FRAME_HEADER_SIZE = 6; + /** Maximum size of a FLAC frame header in bytes. */ + public static final int MAX_FRAME_HEADER_SIZE = 16; + + private FlacConstants() {} +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java new file mode 100644 index 0000000000..71317494e0 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +/** Reads and peeks FLAC frame elements. */ +public final class FlacFrameReader { + + /** Holds a frame block size. */ + public static final class BlockSizeHolder { + /** The block size in samples. */ + public int blockSizeSamples; + } + + /** + * Checks whether the given FLAC frame header is valid and, if so, reads it and writes the frame + * block size in {@code blockSizeHolder}. + * + *

If the header is valid, the position of {@code scratch} is moved to the byte following it. + * Otherwise, there is no guarantee on the position. + * + * @param scratch The array to read the data from, whose position must correspond to the frame + * header. + * @param flacStreamMetadata The stream metadata. + * @param frameStartMarker The frame start marker of the stream. + * @param blockSizeHolder The holder used to contain the block size. + * @return Whether the frame header is valid. + */ + public static boolean checkAndReadFrameHeader( + ParsableByteArray scratch, + FlacStreamMetadata flacStreamMetadata, + int frameStartMarker, + BlockSizeHolder blockSizeHolder) { + int frameStartPosition = scratch.getPosition(); + + long frameHeaderBytes = scratch.readUnsignedInt(); + if (frameHeaderBytes >>> 16 != frameStartMarker) { + return false; + } + + int blockSizeKey = (int) (frameHeaderBytes >> 12 & 0xF); + int sampleRateKey = (int) (frameHeaderBytes >> 8 & 0xF); + int channelAssignmentKey = (int) (frameHeaderBytes >> 4 & 0xF); + int bitsPerSampleKey = (int) (frameHeaderBytes >> 1 & 0x7); + boolean reservedBit = (frameHeaderBytes & 1) == 1; + return checkChannelAssignment(channelAssignmentKey, flacStreamMetadata) + && checkBitsPerSample(bitsPerSampleKey, flacStreamMetadata) + && !reservedBit + && checkAndReadUtf8Data(scratch) + && checkAndReadBlockSizeSamples(scratch, flacStreamMetadata, blockSizeKey, blockSizeHolder) + && checkAndReadSampleRate(scratch, flacStreamMetadata, sampleRateKey) + && checkAndReadCrc(scratch, frameStartPosition); + } + + /** + * Returns the block size of the given frame. + * + *

If no exception is thrown, the position of {@code scratch} is left unchanged. Otherwise, + * there is no guarantee on the position. + * + * @param scratch The array to get the data from, whose position must correspond to the start of a + * frame. + * @return The block size in samples, or -1 if the block size is invalid. + */ + public static int getFrameBlockSizeSamples(ParsableByteArray scratch) { + int blockSizeKey = (scratch.data[2] & 0xFF) >> 4; + if (blockSizeKey < 6 || blockSizeKey > 7) { + return readFrameBlockSizeSamplesFromKey(scratch, blockSizeKey); + } + scratch.skipBytes(4); + scratch.readUtf8EncodedLong(); + int blockSizeSamples = readFrameBlockSizeSamplesFromKey(scratch, blockSizeKey); + scratch.setPosition(0); + return blockSizeSamples; + } + + /** + * Reads the given block size. + * + * @param scratch The array to read the data from, whose position must correspond to the block + * size bits. + * @param blockSizeKey The key in the block size lookup table. + * @return The block size in samples. + */ + public static int readFrameBlockSizeSamplesFromKey(ParsableByteArray scratch, int blockSizeKey) { + switch (blockSizeKey) { + case 1: + return 192; + case 2: + case 3: + case 4: + case 5: + return 576 << (blockSizeKey - 2); + case 6: + return scratch.readUnsignedByte() + 1; + case 7: + return scratch.readUnsignedShort() + 1; + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + return 256 << (blockSizeKey - 8); + default: + return -1; + } + } + + /** + * Checks whether the given channel assignment is valid. + * + * @param channelAssignmentKey The channel assignment lookup key. + * @param flacStreamMetadata The stream metadata. + * @return Whether the channel assignment is valid. + */ + private static boolean checkChannelAssignment( + int channelAssignmentKey, FlacStreamMetadata flacStreamMetadata) { + if (channelAssignmentKey <= 7) { + return channelAssignmentKey == flacStreamMetadata.channels - 1; + } else if (channelAssignmentKey <= 10) { + return flacStreamMetadata.channels == 2; + } else { + return false; + } + } + + /** + * Checks whether the given number of bits per sample is valid. + * + * @param bitsPerSampleKey The bits per sample lookup key. + * @param flacStreamMetadata The stream metadata. + * @return Whether the number of bits per sample is valid. + */ + private static boolean checkBitsPerSample( + int bitsPerSampleKey, FlacStreamMetadata flacStreamMetadata) { + if (bitsPerSampleKey == 0) { + return true; + } + return bitsPerSampleKey == flacStreamMetadata.bitsPerSampleLookupKey; + } + + /** + * Checks whether the given UTF-8 data is valid and, if so, reads it. + * + *

If the UTF-8 data is valid, the position of {@code scratch} is moved to the byte following + * it. Otherwise, there is no guarantee on the position. + * + * @param scratch The array to read the data from, whose position must correspond to the UTF-8 + * data. + * @return Whether the UTF-8 data is valid. + */ + private static boolean checkAndReadUtf8Data(ParsableByteArray scratch) { + try { + scratch.readUtf8EncodedLong(); + } catch (NumberFormatException e) { + return false; + } + return true; + } + + /** + * Checks whether the given frame block size key and block size bits are valid and, if so, reads + * the block size bits and writes the block size in {@code blockSizeHolder}. + * + *

If the block size is valid, the position of {@code scratch} is moved to the byte following + * the block size bits. Otherwise, there is no guarantee on the position. + * + * @param scratch The array to read the data from, whose position must correspond to the block + * size bits. + * @param flacStreamMetadata The stream metadata. + * @param blockSizeKey The key in the block size lookup table. + * @param blockSizeHolder The holder used to contain the block size. + * @return Whether the block size is valid. + */ + private static boolean checkAndReadBlockSizeSamples( + ParsableByteArray scratch, + FlacStreamMetadata flacStreamMetadata, + int blockSizeKey, + BlockSizeHolder blockSizeHolder) { + int blockSizeSamples = readFrameBlockSizeSamplesFromKey(scratch, blockSizeKey); + if (blockSizeSamples == -1 || blockSizeSamples > flacStreamMetadata.maxBlockSizeSamples) { + return false; + } + blockSizeHolder.blockSizeSamples = blockSizeSamples; + return true; + } + + /** + * Checks whether the given sample rate key and sample rate bits are valid and, if so, reads the + * sample rate bits. + * + *

If the sample rate is valid, the position of {@code scratch} is moved to the byte following + * the sample rate bits. Otherwise, there is no guarantee on the position. + * + * @param scratch The array to read the data from, whose position must indicate the sample rate + * bits. + * @param flacStreamMetadata The stream metadata. + * @param sampleRateKey The key in the sample rate lookup table. + * @return Whether the sample rate is valid. + */ + private static boolean checkAndReadSampleRate( + ParsableByteArray scratch, FlacStreamMetadata flacStreamMetadata, int sampleRateKey) { + int expectedSampleRate = flacStreamMetadata.sampleRate; + if (sampleRateKey == 0) { + return true; + } else if (sampleRateKey <= 11) { + return sampleRateKey == flacStreamMetadata.sampleRateLookupKey; + } else if (sampleRateKey == 12) { + return scratch.readUnsignedByte() * 1000 == expectedSampleRate; + } else if (sampleRateKey <= 14) { + int sampleRate = scratch.readUnsignedShort(); + if (sampleRateKey == 14) { + sampleRate *= 10; + } + return sampleRate == expectedSampleRate; + } else { + return false; + } + } + + /** + * Checks whether the given CRC is valid and, if so, reads it. + * + *

If the CRC is valid, the position of {@code scratch} is moved to the byte following it. + * Otherwise, there is no guarantee on the position. + * + *

The {@code scratch} array must contain the whole frame header. + * + * @param scratch The array to read the data from, whose position must indicate the CRC. + * @param frameStartPosition The frame start offset in {@code scratch}. + * @return Whether the CRC is valid. + */ + private static boolean checkAndReadCrc(ParsableByteArray scratch, int frameStartPosition) { + int crc = scratch.readUnsignedByte(); + int frameEndPosition = scratch.getPosition(); + int expectedCrc = + Util.crc8(scratch.data, frameStartPosition, frameEndPosition - 1, /* initialValue= */ 0); + return crc == expectedCrc; + } + + private FlacFrameReader() {} +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java new file mode 100644 index 0000000000..23eefd042c --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.Id3Peeker; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import java.io.IOException; + +/** Reads and peeks FLAC stream metadata elements from an {@link ExtractorInput}. */ +public final class FlacMetadataReader { + + /** Holds the metadata extracted from the first frame. */ + public static final class FirstFrameMetadata { + /** The frame start marker, which should correspond to the 2 first bytes of each frame. */ + public final int frameStartMarker; + /** The block size in samples. */ + public final int blockSizeSamples; + + public FirstFrameMetadata(int frameStartMarker, int blockSizeSamples) { + this.frameStartMarker = frameStartMarker; + this.blockSizeSamples = blockSizeSamples; + } + } + + private static final int STREAM_MARKER = 0x664C6143; // ASCII for "fLaC" + private static final int SYNC_CODE = 0x3FFE; + + /** + * Peeks ID3 Data. + * + * @param input Input stream to peek the ID3 data from. + * @throws IOException If peeking from the input fails. In this case, there is no guarantee on the + * peek position. + * @throws InterruptedException If interrupted while peeking from input. In this case, there is no + * guarantee on the peek position. + */ + public static void peekId3Data(ExtractorInput input) throws IOException, InterruptedException { + new Id3Peeker().peekId3Data(input, Id3Decoder.NO_FRAMES_PREDICATE); + } + + /** + * Peeks the FLAC stream marker. + * + * @param input Input stream to peek the stream marker from. + * @return Whether the data peeked is the FLAC stream marker. + * @throws IOException If peeking from the input fails. In this case, the peek position is left + * unchanged. + * @throws InterruptedException If interrupted while peeking from input. In this case, the peek + * position is left unchanged. + */ + public static boolean checkAndPeekStreamMarker(ExtractorInput input) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); + input.peekFully(scratch.data, /* offset= */ 0, FlacConstants.STREAM_MARKER_SIZE); + return scratch.readUnsignedInt() == STREAM_MARKER; + } + + /** + * Reads ID3 Data. + * + *

If no exception is thrown, the peek position of {@code input} is aligned with the read + * position. + * + * @param input Input stream to read the ID3 data from. + * @throws IOException If reading from the input fails. In this case, the read position is left + * unchanged and there is no guarantee on the peek position. + * @throws InterruptedException If interrupted while reading from input. In this case, the read + * position is left unchanged and there is no guarantee on the peek position. + */ + public static void readId3Data(ExtractorInput input) throws IOException, InterruptedException { + input.resetPeekPosition(); + long startingPeekPosition = input.getPeekPosition(); + new Id3Peeker().peekId3Data(input, Id3Decoder.NO_FRAMES_PREDICATE); + int peekedId3Bytes = (int) (input.getPeekPosition() - startingPeekPosition); + input.skipFully(peekedId3Bytes); + } + + /** + * Reads the FLAC stream marker. + * + * @param input Input stream to read the stream marker from. + * @param scratchData The array in which the data read should be copied. This array must have size + * at least {@code scratchWriteIndex} + {@link FlacConstants#STREAM_MARKER_SIZE}. + * @param scratchWriteIndex The index of {@code scratchData} from which to write. + * @throws ParserException If an error occurs parsing the stream marker. In this case, the + * position of {@code input} is advanced by {@link FlacConstants#STREAM_MARKER_SIZE} bytes. + * @throws IOException If reading from the input fails. In this case, the position is left + * unchanged. + * @throws InterruptedException If interrupted while reading from input. In this case, the + * position is left unchanged. + */ + public static void readStreamMarker( + ExtractorInput input, byte[] scratchData, int scratchWriteIndex) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(scratchData); + input.readFully( + scratch.data, + /* offset= */ scratchWriteIndex, + /* length= */ FlacConstants.STREAM_MARKER_SIZE); + scratch.setPosition(scratchWriteIndex); + if (scratch.readUnsignedInt() != STREAM_MARKER) { + throw new ParserException("Failed to read FLAC stream marker."); + } + } + + /** + * Reads the stream info block. + * + * @param input Input stream to read the stream info block from. + * @param scratchData The array in which the data read should be copied. This array must have size + * at least {@code scratchWriteIndex} + {@link FlacConstants#STREAM_INFO_BLOCK_SIZE}. + * @param scratchWriteIndex The index of {@code scratchData} from which to write. + * @return A new {@link FlacStreamMetadata} read from {@code input}. + * @throws IOException If reading from the input fails. In this case, the position is left + * unchanged. + * @throws InterruptedException If interrupted while reading from input. In this case, the + * position is left unchanged. + */ + public static FlacStreamMetadata readStreamInfoBlock( + ExtractorInput input, byte[] scratchData, int scratchWriteIndex) + throws IOException, InterruptedException { + input.readFully( + scratchData, + /* offset= */ scratchWriteIndex, + /* length= */ FlacConstants.STREAM_INFO_BLOCK_SIZE); + return new FlacStreamMetadata( + scratchData, /* offset= */ scratchWriteIndex + FlacConstants.METADATA_BLOCK_HEADER_SIZE); + } + + /** + * Skips the stream metadata blocks. + * + *

If no exception is thrown, the peek position of {@code input} is aligned with the read + * position. + * + * @param input Input stream to read the metadata blocks from. + * @throws IOException If reading from the input fails. In this case, the read position will be at + * the start of a metadata block and there is no guarantee on the peek position. + * @throws InterruptedException If interrupted while reading from input. In this case, the read + * position will be at the start of a metadata block and there is no guarantee on the peek + * position. + */ + public static void skipMetadataBlocks(ExtractorInput input) + throws IOException, InterruptedException { + input.resetPeekPosition(); + ParsableBitArray scratch = new ParsableBitArray(new byte[4]); + boolean lastMetadataBlock = false; + while (!lastMetadataBlock) { + input.peekFully(scratch.data, /* offset= */ 0, /* length= */ 4); + scratch.setPosition(0); + lastMetadataBlock = scratch.readBit(); + scratch.skipBits(7); + int length = scratch.readBits(24); + input.skipFully(4 + length); + } + } + + /** + * Returns some metadata extracted from the first frame of a FLAC stream. + * + *

The read position of {@code input} is left unchanged and the peek position is aligned with + * the read position. + * + * @param input Input stream to get the metadata from (starting from the read position). + * @return A {@link FirstFrameMetadata} containing the frame start marker (which should be the + * same for all the frames in the stream) and the block size of the frame. + * @throws ParserException If an error occurs parsing the frame metadata. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + */ + public static FirstFrameMetadata getFirstFrameMetadata(ExtractorInput input) + throws IOException, InterruptedException { + input.resetPeekPosition(); + ParsableByteArray scratch = + new ParsableByteArray(new byte[FlacConstants.MAX_FRAME_HEADER_SIZE]); + input.peekFully(scratch.data, /* offset= */ 0, FlacConstants.MAX_FRAME_HEADER_SIZE); + + int frameStartMarker = scratch.readUnsignedShort(); + int syncCode = frameStartMarker >> 2; + if (syncCode != SYNC_CODE) { + input.resetPeekPosition(); + throw new ParserException("First frame does not start with sync code."); + } + + scratch.setPosition(0); + int firstFrameBlockSizeSamples = FlacFrameReader.getFrameBlockSizeSamples(scratch); + + input.resetPeekPosition(); + return new FirstFrameMetadata(frameStartMarker, firstFrameBlockSizeSamples); + } + + private FlacMetadataReader() {} +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java index 9c5862b483..b35d585a05 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java @@ -17,10 +17,12 @@ package com.google.android.exoplayer2.util; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.flac.PictureFrame; import com.google.android.exoplayer2.metadata.flac.VorbisComment; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** Holder for FLAC metadata. */ @@ -28,14 +30,45 @@ public final class FlacStreamMetadata { private static final String TAG = "FlacStreamMetadata"; - public final int minBlockSize; - public final int maxBlockSize; + /** Indicates that a value is not in the corresponding lookup table. */ + public static final int NOT_IN_LOOKUP_TABLE = -1; + + /** Minimum number of samples per block. */ + public final int minBlockSizeSamples; + /** Maximum number of samples per block. */ + public final int maxBlockSizeSamples; + /** Minimum frame size in bytes, or 0 if the value is unknown. */ public final int minFrameSize; + /** Maximum frame size in bytes, or 0 if the value is unknown. */ public final int maxFrameSize; + /** Sample rate in Hertz. */ public final int sampleRate; + /** + * Lookup key corresponding to the stream sample rate, or {@link #NOT_IN_LOOKUP_TABLE} if it is + * not in the lookup table. + * + *

This key is used to indicate the sample rate in the frame header for the most common values. + * + *

The sample rate lookup table is described in https://xiph.org/flac/format.html#frame_header. + */ + public final int sampleRateLookupKey; + /** Number of audio channels. */ public final int channels; + /** Number of bits per sample. */ public final int bitsPerSample; + /** + * Lookup key corresponding to the number of bits per sample of the stream, or {@link + * #NOT_IN_LOOKUP_TABLE} if it is not in the lookup table. + * + *

This key is used to indicate the number of bits per sample in the frame header for the most + * common values. + * + *

The sample size lookup table is described in https://xiph.org/flac/format.html#frame_header. + */ + public final int bitsPerSampleLookupKey; + /** Total number of samples, or 0 if the value is unknown. */ public final long totalSamples; + /** Stream content metadata. */ @Nullable public final Metadata metadata; private static final String SEPARATOR = "="; @@ -44,27 +77,29 @@ public final class FlacStreamMetadata { * Parses binary FLAC stream info metadata. * * @param data An array containing binary FLAC stream info metadata. - * @param offset The offset of the stream info metadata in {@code data}. + * @param offset The offset of the stream info block in {@code data} (header excluded). * @see FLAC format * METADATA_BLOCK_STREAMINFO */ public FlacStreamMetadata(byte[] data, int offset) { ParsableBitArray scratch = new ParsableBitArray(data); scratch.setPosition(offset * 8); - this.minBlockSize = scratch.readBits(16); - this.maxBlockSize = scratch.readBits(16); + this.minBlockSizeSamples = scratch.readBits(16); + this.maxBlockSizeSamples = scratch.readBits(16); this.minFrameSize = scratch.readBits(24); this.maxFrameSize = scratch.readBits(24); this.sampleRate = scratch.readBits(20); + this.sampleRateLookupKey = getSampleRateLookupKey(); this.channels = scratch.readBits(3) + 1; this.bitsPerSample = scratch.readBits(5) + 1; - this.totalSamples = ((scratch.readBits(4) & 0xFL) << 32) | (scratch.readBits(32) & 0xFFFFFFFFL); + this.bitsPerSampleLookupKey = getBitsPerSampleLookupKey(); + this.totalSamples = scratch.readBitsToLong(36); this.metadata = null; } /** - * @param minBlockSize Minimum block size of the FLAC stream. - * @param maxBlockSize Maximum block size of the FLAC stream. + * @param minBlockSizeSamples Minimum block size of the FLAC stream. + * @param maxBlockSizeSamples Maximum block size of the FLAC stream. * @param minFrameSize Minimum frame size of the FLAC stream. * @param maxFrameSize Maximum frame size of the FLAC stream. * @param sampleRate Sample rate of the FLAC stream. @@ -81,8 +116,8 @@ public final class FlacStreamMetadata { * METADATA_BLOCK_PICTURE */ public FlacStreamMetadata( - int minBlockSize, - int maxBlockSize, + int minBlockSizeSamples, + int maxBlockSizeSamples, int minFrameSize, int maxFrameSize, int sampleRate, @@ -91,30 +126,35 @@ public final class FlacStreamMetadata { long totalSamples, List vorbisComments, List pictureFrames) { - this.minBlockSize = minBlockSize; - this.maxBlockSize = maxBlockSize; + this.minBlockSizeSamples = minBlockSizeSamples; + this.maxBlockSizeSamples = maxBlockSizeSamples; this.minFrameSize = minFrameSize; this.maxFrameSize = maxFrameSize; this.sampleRate = sampleRate; + this.sampleRateLookupKey = getSampleRateLookupKey(); this.channels = channels; this.bitsPerSample = bitsPerSample; + this.bitsPerSampleLookupKey = getBitsPerSampleLookupKey(); this.totalSamples = totalSamples; - this.metadata = buildMetadata(vorbisComments, pictureFrames); + this.metadata = getMetadata(vorbisComments, pictureFrames); } /** Returns the maximum size for a decoded frame from the FLAC stream. */ - public int maxDecodedFrameSize() { - return maxBlockSize * channels * (bitsPerSample / 8); + public int getMaxDecodedFrameSize() { + return maxBlockSizeSamples * channels * (bitsPerSample / 8); } /** Returns the bit-rate of the FLAC stream. */ - public int bitRate() { + public int getBitRate() { return bitsPerSample * sampleRate * channels; } - /** Returns the duration of the FLAC stream in microseconds. */ - public long durationUs() { - return (totalSamples * 1000000L) / sampleRate; + /** + * Returns the duration of the FLAC stream in microseconds, or {@link C#TIME_UNSET} if the total + * number of samples if unknown. + */ + public long getDurationUs() { + return totalSamples == 0 ? C.TIME_UNSET : totalSamples * C.MICROS_PER_SECOND / sampleRate; } /** @@ -125,7 +165,7 @@ public final class FlacStreamMetadata { */ public long getSampleIndex(long timeUs) { long sampleIndex = (timeUs * sampleRate) / C.MICROS_PER_SECOND; - return Util.constrainValue(sampleIndex, 0, totalSamples - 1); + return Util.constrainValue(sampleIndex, /* min= */ 0, totalSamples - 1); } /** Returns the approximate number of bytes per frame for the current FLAC stream. */ @@ -136,14 +176,91 @@ public final class FlacStreamMetadata { } else { // Uses the stream's block-size if it's a known fixed block-size stream, otherwise uses the // default value for FLAC block-size, which is 4096. - long blockSize = (minBlockSize == maxBlockSize && minBlockSize > 0) ? minBlockSize : 4096; - approxBytesPerFrame = (blockSize * channels * bitsPerSample) / 8 + 64; + long blockSizeSamples = + (minBlockSizeSamples == maxBlockSizeSamples && minBlockSizeSamples > 0) + ? minBlockSizeSamples + : 4096; + approxBytesPerFrame = (blockSizeSamples * channels * bitsPerSample) / 8 + 64; } return approxBytesPerFrame; } + /** + * Returns a {@link Format} extracted from the FLAC stream metadata. + * + *

{@code streamMarkerAndInfoBlock} is updated to set the bit corresponding to the stream info + * last metadata block flag to true. + * + * @param streamMarkerAndInfoBlock An array containing the FLAC stream marker followed by the + * stream info block. + * @return The extracted {@link Format}. + */ + public Format getFormat(byte[] streamMarkerAndInfoBlock) { + // Set the last metadata block flag, ignore the other blocks. + streamMarkerAndInfoBlock[4] = (byte) 0x80; + int maxInputSize = maxFrameSize > 0 ? maxFrameSize : Format.NO_VALUE; + return Format.createAudioSampleFormat( + /* id= */ null, + MimeTypes.AUDIO_FLAC, + /* codecs= */ null, + getBitRate(), + maxInputSize, + channels, + sampleRate, + Collections.singletonList(streamMarkerAndInfoBlock), + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + } + + private int getSampleRateLookupKey() { + switch (sampleRate) { + case 88200: + return 1; + case 176400: + return 2; + case 192000: + return 3; + case 8000: + return 4; + case 16000: + return 5; + case 22050: + return 6; + case 24000: + return 7; + case 32000: + return 8; + case 44100: + return 9; + case 48000: + return 10; + case 96000: + return 11; + default: + return NOT_IN_LOOKUP_TABLE; + } + } + + private int getBitsPerSampleLookupKey() { + switch (bitsPerSample) { + case 8: + return 1; + case 12: + return 2; + case 16: + return 4; + case 20: + return 5; + case 24: + return 6; + default: + return NOT_IN_LOOKUP_TABLE; + } + } + @Nullable - private static Metadata buildMetadata( + private static Metadata getMetadata( List vorbisComments, List pictureFrames) { if (vorbisComments.isEmpty() && pictureFrames.isEmpty()) { return null; diff --git a/library/core/src/test/assets/flac/bear.flac b/library/core/src/test/assets/flac/bear.flac new file mode 100644 index 0000000000000000000000000000000000000000..3e17983fef7a7945937f59a43a4254cd1e06e16b GIT binary patch literal 173311 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

r|S{ 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 Z#KW8x2II21w5q$)GM#h4lx=I>{{!06{3iea literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear.flac.0.dump b/library/core/src/test/assets/flac/bear.flac.0.dump new file mode 100644 index 0000000000..e35dcc2081 --- /dev/null +++ b/library/core/src/test/assets/flac/bear.flac.0.dump @@ -0,0 +1,163 @@ +seekMap: + isSeekable = false + duration = 2741000 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + format: + bitrate = 1536000 + id = null + containerMimeType = null + sampleMimeType = audio/flac + maxInputSize = 5776 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 42, hash 83F6895 + total output bytes = 164431 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 5030, hash D2B60530 + sample 1: + time = 85333 + flags = 1 + data = length 5066, hash 4C932A54 + sample 2: + time = 170666 + flags = 1 + data = length 5112, hash 7E5A7B61 + sample 3: + time = 256000 + flags = 1 + data = length 5044, hash 7EF93F13 + sample 4: + time = 341333 + flags = 1 + data = length 4943, hash DE7E27F8 + sample 5: + time = 426666 + flags = 1 + data = length 5121, hash 6D0D0B40 + sample 6: + time = 512000 + flags = 1 + data = length 5068, hash 9924644F + sample 7: + time = 597333 + flags = 1 + data = length 5143, hash 6C34F0CE + sample 8: + time = 682666 + flags = 1 + data = length 5109, hash E3B7BEFB + sample 9: + time = 768000 + flags = 1 + data = length 5129, hash 44111D9B + sample 10: + time = 853333 + flags = 1 + data = length 5031, hash 9D55EA53 + sample 11: + time = 938666 + flags = 1 + data = length 5119, hash E1CB9BA6 + sample 12: + time = 1024000 + flags = 1 + data = length 5360, hash 17265C5D + sample 13: + time = 1109333 + flags = 1 + data = length 5340, hash A90FDDF1 + sample 14: + time = 1194666 + flags = 1 + data = length 5162, hash 31F65AD5 + sample 15: + time = 1280000 + flags = 1 + data = length 5168, hash F2394F2D + sample 16: + time = 1365333 + flags = 1 + data = length 5776, hash 58437AB3 + sample 17: + time = 1450666 + flags = 1 + data = length 5394, hash EBAB20A8 + sample 18: + time = 1536000 + flags = 1 + data = length 5168, hash BF37C7A5 + sample 19: + time = 1621333 + flags = 1 + data = length 5324, hash 59546B7B + sample 20: + time = 1706666 + flags = 1 + data = length 5172, hash 6036EF0B + sample 21: + time = 1792000 + flags = 1 + data = length 5102, hash 5A131071 + sample 22: + time = 1877333 + flags = 1 + data = length 5111, hash 3D9EBB3B + sample 23: + time = 1962666 + flags = 1 + data = length 5113, hash 61101D4F + sample 24: + time = 2048000 + flags = 1 + data = length 5229, hash D2E55742 + sample 25: + time = 2133333 + flags = 1 + data = length 5162, hash 7F2E97FA + sample 26: + time = 2218666 + flags = 1 + data = length 5255, hash D92A782 + sample 27: + time = 2304000 + flags = 1 + data = length 5196, hash 98FE5138 + sample 28: + time = 2389333 + flags = 1 + data = length 5214, hash 3D35C38C + sample 29: + time = 2474666 + flags = 1 + data = length 5211, hash 7E25420F + sample 30: + time = 2560000 + flags = 1 + data = length 5230, hash 2AD96FBC + sample 31: + time = 2645333 + flags = 1 + data = length 3384, hash 938BCDD9 + sample 32: + time = 2730666 + flags = 1 + data = length 445, hash A388E3D6 +tracksEnded = true diff --git a/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac new file mode 100644 index 0000000000000000000000000000000000000000..fb0c9d207eb951360cbdc09d13c76035e0f2ce39 GIT binary patch literal 173311 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
XMb1D;k2Y$ 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 VMZvgyF0JnFv`pt5FlF0X_y0y_`ojPK literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump new file mode 100644 index 0000000000..2c394e71b7 --- /dev/null +++ b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump @@ -0,0 +1,163 @@ +seekMap: + isSeekable = false + duration = 2741000 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + format: + bitrate = 1536000 + id = null + containerMimeType = null + sampleMimeType = audio/flac + maxInputSize = -1 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 42, hash 9218FDB7 + total output bytes = 164431 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 5030, hash D2B60530 + sample 1: + time = 85333 + flags = 1 + data = length 5066, hash 4C932A54 + sample 2: + time = 170666 + flags = 1 + data = length 5112, hash 7E5A7B61 + sample 3: + time = 256000 + flags = 1 + data = length 5044, hash 7EF93F13 + sample 4: + time = 341333 + flags = 1 + data = length 4943, hash DE7E27F8 + sample 5: + time = 426666 + flags = 1 + data = length 5121, hash 6D0D0B40 + sample 6: + time = 512000 + flags = 1 + data = length 5068, hash 9924644F + sample 7: + time = 597333 + flags = 1 + data = length 5143, hash 6C34F0CE + sample 8: + time = 682666 + flags = 1 + data = length 5109, hash E3B7BEFB + sample 9: + time = 768000 + flags = 1 + data = length 5129, hash 44111D9B + sample 10: + time = 853333 + flags = 1 + data = length 5031, hash 9D55EA53 + sample 11: + time = 938666 + flags = 1 + data = length 5119, hash E1CB9BA6 + sample 12: + time = 1024000 + flags = 1 + data = length 5360, hash 17265C5D + sample 13: + time = 1109333 + flags = 1 + data = length 5340, hash A90FDDF1 + sample 14: + time = 1194666 + flags = 1 + data = length 5162, hash 31F65AD5 + sample 15: + time = 1280000 + flags = 1 + data = length 5168, hash F2394F2D + sample 16: + time = 1365333 + flags = 1 + data = length 5776, hash 58437AB3 + sample 17: + time = 1450666 + flags = 1 + data = length 5394, hash EBAB20A8 + sample 18: + time = 1536000 + flags = 1 + data = length 5168, hash BF37C7A5 + sample 19: + time = 1621333 + flags = 1 + data = length 5324, hash 59546B7B + sample 20: + time = 1706666 + flags = 1 + data = length 5172, hash 6036EF0B + sample 21: + time = 1792000 + flags = 1 + data = length 5102, hash 5A131071 + sample 22: + time = 1877333 + flags = 1 + data = length 5111, hash 3D9EBB3B + sample 23: + time = 1962666 + flags = 1 + data = length 5113, hash 61101D4F + sample 24: + time = 2048000 + flags = 1 + data = length 5229, hash D2E55742 + sample 25: + time = 2133333 + flags = 1 + data = length 5162, hash 7F2E97FA + sample 26: + time = 2218666 + flags = 1 + data = length 5255, hash D92A782 + sample 27: + time = 2304000 + flags = 1 + data = length 5196, hash 98FE5138 + sample 28: + time = 2389333 + flags = 1 + data = length 5214, hash 3D35C38C + sample 29: + time = 2474666 + flags = 1 + data = length 5211, hash 7E25420F + sample 30: + time = 2560000 + flags = 1 + data = length 5230, hash 2AD96FBC + sample 31: + time = 2645333 + flags = 1 + data = length 3384, hash 938BCDD9 + sample 32: + time = 2730666 + flags = 1 + data = length 445, hash A388E3D6 +tracksEnded = true diff --git a/library/core/src/test/assets/flac/bear_no_num_samples.flac b/library/core/src/test/assets/flac/bear_no_num_samples.flac new file mode 100644 index 0000000000000000000000000000000000000000..03696047cd9879a32792a5ebbc4624801b7e3cb0 GIT binary patch literal 173311 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
XMb1D;k2Y$ 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 VMZvgyF0JnFv`pt5FlF0X_y0IL_-OzD literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump b/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump new file mode 100644 index 0000000000..c913738be5 --- /dev/null +++ b/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump @@ -0,0 +1,163 @@ +seekMap: + isSeekable = false + duration = UNSET TIME + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + format: + bitrate = 1536000 + id = null + containerMimeType = null + sampleMimeType = audio/flac + maxInputSize = 5776 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 42, hash 49FA2C21 + total output bytes = 164431 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 5030, hash D2B60530 + sample 1: + time = 85333 + flags = 1 + data = length 5066, hash 4C932A54 + sample 2: + time = 170666 + flags = 1 + data = length 5112, hash 7E5A7B61 + sample 3: + time = 256000 + flags = 1 + data = length 5044, hash 7EF93F13 + sample 4: + time = 341333 + flags = 1 + data = length 4943, hash DE7E27F8 + sample 5: + time = 426666 + flags = 1 + data = length 5121, hash 6D0D0B40 + sample 6: + time = 512000 + flags = 1 + data = length 5068, hash 9924644F + sample 7: + time = 597333 + flags = 1 + data = length 5143, hash 6C34F0CE + sample 8: + time = 682666 + flags = 1 + data = length 5109, hash E3B7BEFB + sample 9: + time = 768000 + flags = 1 + data = length 5129, hash 44111D9B + sample 10: + time = 853333 + flags = 1 + data = length 5031, hash 9D55EA53 + sample 11: + time = 938666 + flags = 1 + data = length 5119, hash E1CB9BA6 + sample 12: + time = 1024000 + flags = 1 + data = length 5360, hash 17265C5D + sample 13: + time = 1109333 + flags = 1 + data = length 5340, hash A90FDDF1 + sample 14: + time = 1194666 + flags = 1 + data = length 5162, hash 31F65AD5 + sample 15: + time = 1280000 + flags = 1 + data = length 5168, hash F2394F2D + sample 16: + time = 1365333 + flags = 1 + data = length 5776, hash 58437AB3 + sample 17: + time = 1450666 + flags = 1 + data = length 5394, hash EBAB20A8 + sample 18: + time = 1536000 + flags = 1 + data = length 5168, hash BF37C7A5 + sample 19: + time = 1621333 + flags = 1 + data = length 5324, hash 59546B7B + sample 20: + time = 1706666 + flags = 1 + data = length 5172, hash 6036EF0B + sample 21: + time = 1792000 + flags = 1 + data = length 5102, hash 5A131071 + sample 22: + time = 1877333 + flags = 1 + data = length 5111, hash 3D9EBB3B + sample 23: + time = 1962666 + flags = 1 + data = length 5113, hash 61101D4F + sample 24: + time = 2048000 + flags = 1 + data = length 5229, hash D2E55742 + sample 25: + time = 2133333 + flags = 1 + data = length 5162, hash 7F2E97FA + sample 26: + time = 2218666 + flags = 1 + data = length 5255, hash D92A782 + sample 27: + time = 2304000 + flags = 1 + data = length 5196, hash 98FE5138 + sample 28: + time = 2389333 + flags = 1 + data = length 5214, hash 3D35C38C + sample 29: + time = 2474666 + flags = 1 + data = length 5211, hash 7E25420F + sample 30: + time = 2560000 + flags = 1 + data = length 5230, hash 2AD96FBC + sample 31: + time = 2645333 + flags = 1 + data = length 3384, hash 938BCDD9 + sample 32: + time = 2730666 + flags = 1 + data = length 445, hash A388E3D6 +tracksEnded = true diff --git a/library/core/src/test/assets/flac/bear_one_metadata_block.flac b/library/core/src/test/assets/flac/bear_one_metadata_block.flac new file mode 100644 index 0000000000000000000000000000000000000000..a3fcb8f114b0910b1bf9e473a35f57c11925bb22 GIT binary patch literal 164473 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

Bi666c$l`=&J_ciz!N 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

  • !5&QsB&p68+<;s1cpSx?GB^uoCb3L1gJ zNM%qcDqkKeAkrM%1WdbIXnQt9jIP#E`oc@+xdkIA>h!rdCU&Q1}*0)>OZ z3H+VHK`tnSqMV)F8aOZtRp!fvNZIx&ju#DZduy>C1#?GHs8lK?iUEOAV@r@!Iru5U z-@~q$jiBaR1$cW)RFphcnv8ZcP{HcBM+7tNEr_uV4nv_(FboX>L&+IVP86_s#l?jX zUo(nYTt&f9iw`%tJ0mIiHn)bujYy39%iF17EzK_m?#0w!5xw3MX(biP|0S0 zniDK9SZSByU*ZmSKve$K2C!~XXa`@N+MQxOYyjU9c#S)T;oAgvP*IEZ7zT>ro^QoD zN01R1uo0uGs5obg0{qvG1w|N{5<;OtP}CBX0KT&b>wtrUWF;4 z6tl5%y+79(n%szWK&JQ?BGZU8=(rWK-~Vl{@g4u}joY|0sMPLSEcUtxWc$j|{D-O1 zV@KHgF=_iT(LT%n*U|aBBn-x3oS=(P;14V|Pt*C*LP4hvy4qN3)?tW{c3LZj?BbtL zbnvZ&O`Wpv;+O}+4$c08@X$d)Ex|)q`#bvDd;VVEFVFk(JkQ6rU!5>p8~&Wv%lxdn z^fuDW3tgEf6`U=rOJB{#O|{kSq!gXYVA57hk4}<_bc*0Vr&!Z4-X(et`OMKsn9ROS zB2KaFRW;-dP*l$cgEG3wSA>a{Q=e95fJI2))r`UmyfB`aZxowE=`^B)lC0ia!c-q| zvXHOjYq(DIyrk}NICa&UyW9rme^;8O>u${?eIb~xn{bOZXC$k+a8Np~9$DP~J8`yU zTwAfTs2|p}$wV#?RE|L*-^LYmvK^;xciPbc&}gA&5dIotkY;W{Pg*}4DHSl}jsYIh z+&2b{;e%-BV$t-2vcy1L7|+Z|2{ED}t-+Ba#gWHwS=BuB9=4;RSbW5dFZu&CbKk($ zLUyDDq!`y0M6QAD0e&LU0wghYw+eY}s3{bS?Lz=epYni*>Fhli2k%x~xhxC^C6H_h F@(;o_TM7UG literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear_with_id3.flac.0.dump b/library/core/src/test/assets/flac/bear_with_id3.flac.0.dump new file mode 100644 index 0000000000..e35dcc2081 --- /dev/null +++ b/library/core/src/test/assets/flac/bear_with_id3.flac.0.dump @@ -0,0 +1,163 @@ +seekMap: + isSeekable = false + duration = 2741000 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + format: + bitrate = 1536000 + id = null + containerMimeType = null + sampleMimeType = audio/flac + maxInputSize = 5776 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 42, hash 83F6895 + total output bytes = 164431 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 5030, hash D2B60530 + sample 1: + time = 85333 + flags = 1 + data = length 5066, hash 4C932A54 + sample 2: + time = 170666 + flags = 1 + data = length 5112, hash 7E5A7B61 + sample 3: + time = 256000 + flags = 1 + data = length 5044, hash 7EF93F13 + sample 4: + time = 341333 + flags = 1 + data = length 4943, hash DE7E27F8 + sample 5: + time = 426666 + flags = 1 + data = length 5121, hash 6D0D0B40 + sample 6: + time = 512000 + flags = 1 + data = length 5068, hash 9924644F + sample 7: + time = 597333 + flags = 1 + data = length 5143, hash 6C34F0CE + sample 8: + time = 682666 + flags = 1 + data = length 5109, hash E3B7BEFB + sample 9: + time = 768000 + flags = 1 + data = length 5129, hash 44111D9B + sample 10: + time = 853333 + flags = 1 + data = length 5031, hash 9D55EA53 + sample 11: + time = 938666 + flags = 1 + data = length 5119, hash E1CB9BA6 + sample 12: + time = 1024000 + flags = 1 + data = length 5360, hash 17265C5D + sample 13: + time = 1109333 + flags = 1 + data = length 5340, hash A90FDDF1 + sample 14: + time = 1194666 + flags = 1 + data = length 5162, hash 31F65AD5 + sample 15: + time = 1280000 + flags = 1 + data = length 5168, hash F2394F2D + sample 16: + time = 1365333 + flags = 1 + data = length 5776, hash 58437AB3 + sample 17: + time = 1450666 + flags = 1 + data = length 5394, hash EBAB20A8 + sample 18: + time = 1536000 + flags = 1 + data = length 5168, hash BF37C7A5 + sample 19: + time = 1621333 + flags = 1 + data = length 5324, hash 59546B7B + sample 20: + time = 1706666 + flags = 1 + data = length 5172, hash 6036EF0B + sample 21: + time = 1792000 + flags = 1 + data = length 5102, hash 5A131071 + sample 22: + time = 1877333 + flags = 1 + data = length 5111, hash 3D9EBB3B + sample 23: + time = 1962666 + flags = 1 + data = length 5113, hash 61101D4F + sample 24: + time = 2048000 + flags = 1 + data = length 5229, hash D2E55742 + sample 25: + time = 2133333 + flags = 1 + data = length 5162, hash 7F2E97FA + sample 26: + time = 2218666 + flags = 1 + data = length 5255, hash D92A782 + sample 27: + time = 2304000 + flags = 1 + data = length 5196, hash 98FE5138 + sample 28: + time = 2389333 + flags = 1 + data = length 5214, hash 3D35C38C + sample 29: + time = 2474666 + flags = 1 + data = length 5211, hash 7E25420F + sample 30: + time = 2560000 + flags = 1 + data = length 5230, hash 2AD96FBC + sample 31: + time = 2645333 + flags = 1 + data = length 3384, hash 938BCDD9 + sample 32: + time = 2730666 + flags = 1 + data = length 445, hash A388E3D6 +tracksEnded = true diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java index 43ff7d8f51..ace30dbaf5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; +import com.google.android.exoplayer2.extractor.flac.FlacExtractor; import com.google.android.exoplayer2.extractor.flv.FlvExtractor; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; @@ -64,7 +65,8 @@ public final class DefaultExtractorsFactoryTest { PsExtractor.class, WavExtractor.class, AmrExtractor.class, - Ac4Extractor.class + Ac4Extractor.class, + FlacExtractor.class }; assertThat(listCreatedExtractorClasses).containsNoDuplicates(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java new file mode 100644 index 0000000000..3aac12a1a3 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.flac; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link FlacExtractor}. */ +@RunWith(AndroidJUnit4.class) +public class FlacExtractorTest { + + @Test + public void testSample() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear.flac"); + } + + @Test + public void testSampleWithId3() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_with_id3.flac"); + } + + @Test + public void testOneMetadataBlock() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_one_metadata_block.flac"); + } + + @Test + public void testNoMinMaxFrameSize() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_no_min_max_frame_size.flac"); + } + + @Test + public void testNoNumSamples() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_no_num_samples.flac"); + } + + @Test + public void testUncommonSampleRate() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_uncommon_sample_rate.flac"); + } +} From a558501e389b7f41a5c07ca18835b08454c378b4 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 19 Nov 2019 10:45:58 +0000 Subject: [PATCH 0033/1052] Remove TODO around optimizing sample data outputs in FlacExtractor Preliminary measurement showed that the results were similar to the non-optimized version. PiperOrigin-RevId: 281255476 --- .../google/android/exoplayer2/extractor/flac/FlacExtractor.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java index 33f608788b..1ca010dbb1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -202,8 +202,6 @@ public final class FlacExtractor implements Extractor { state = STATE_READ_FRAMES; } - // TODO: consider sending bytes within min frame size directly from the input to the sample queue - // to avoid unnecessary copies in scratch. private int readFrames(ExtractorInput input) throws IOException, InterruptedException { Assertions.checkNotNull(trackOutput); Assertions.checkNotNull(flacStreamMetadata); From f09f62da4f541ca4de90e9adf95b9c5c1a53d3a2 Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 20 Nov 2019 17:40:03 +0000 Subject: [PATCH 0034/1052] Expose metadata in FLAC extractor PiperOrigin-RevId: 281538423 --- RELEASENOTES.md | 29 +- .../exoplayer2/ext/flac/FlacExtractor.java | 33 +- extensions/flac/src/main/jni/flac_jni.cc | 2 +- .../{util => extractor}/FlacFrameReader.java | 6 +- .../extractor/FlacMetadataReader.java | 281 +++++ .../extractor/{ogg => }/VorbisBitArray.java | 6 +- .../extractor/{ogg => }/VorbisUtil.java | 209 ++-- .../extractor/flac/FlacExtractor.java | 122 +- .../exoplayer2/extractor/ogg/FlacReader.java | 2 +- .../extractor/ogg/VorbisReader.java | 3 +- .../exoplayer2/util/FlacMetadataReader.java | 208 ---- .../exoplayer2/util/FlacStreamMetadata.java | 159 ++- .../assets/binary/ogg/vorbis_header_pages | Bin 0 -> 3743 bytes .../test/assets/binary/vorbis/comment_header | Bin 0 -> 113 bytes .../src/test/assets/binary/vorbis/id_header | Bin 0 -> 30 bytes .../test/assets/binary/vorbis/setup_header | Bin 0 -> 3597 bytes library/core/src/test/assets/flac/bear.flac | Bin 173311 -> 173311 bytes .../flac/bear_no_min_max_frame_size.flac | Bin 173311 -> 173311 bytes .../test/assets/flac/bear_no_num_samples.flac | Bin 173311 -> 173311 bytes .../flac/bear_uncommon_sample_rate.flac | Bin 152374 -> 152374 bytes .../src/test/assets/flac/bear_with_id3.flac | Bin 219715 -> 219715 bytes .../test/assets/flac/bear_with_picture.flac | Bin 0 -> 204299 bytes .../assets/flac/bear_with_picture.flac.0.dump | 163 +++ .../flac/bear_with_vorbis_comments.flac | Bin 0 -> 173311 bytes .../bear_with_vorbis_comments.flac.0.dump | 163 +++ .../{ogg => }/VorbisBitArrayTest.java | 2 +- .../extractor/{ogg => }/VorbisUtilTest.java | 25 +- .../extractor/flac/FlacExtractorTest.java | 22 +- .../exoplayer2/extractor/ogg/OggTestData.java | 1008 ----------------- .../extractor/ogg/VorbisReaderTest.java | 8 +- .../util/FlacStreamMetadataTest.java | 54 +- .../exoplayer2/testutil/ExtractorAsserts.java | 1 + 32 files changed, 1051 insertions(+), 1455 deletions(-) rename library/core/src/main/java/com/google/android/exoplayer2/{util => extractor}/FlacFrameReader.java (97%) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java rename library/core/src/main/java/com/google/android/exoplayer2/extractor/{ogg => }/VorbisBitArray.java (96%) rename library/core/src/main/java/com/google/android/exoplayer2/extractor/{ogg => }/VorbisUtil.java (85%) delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java create mode 100644 library/core/src/test/assets/binary/ogg/vorbis_header_pages create mode 100644 library/core/src/test/assets/binary/vorbis/comment_header create mode 100644 library/core/src/test/assets/binary/vorbis/id_header create mode 100644 library/core/src/test/assets/binary/vorbis/setup_header create mode 100644 library/core/src/test/assets/flac/bear_with_picture.flac create mode 100644 library/core/src/test/assets/flac/bear_with_picture.flac.0.dump create mode 100644 library/core/src/test/assets/flac/bear_with_vorbis_comments.flac create mode 100644 library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump rename library/core/src/test/java/com/google/android/exoplayer2/extractor/{ogg => }/VorbisBitArrayTest.java (99%) rename library/core/src/test/java/com/google/android/exoplayer2/extractor/{ogg => }/VorbisUtilTest.java (85%) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a1b79518a9..963d08518a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,9 +4,6 @@ * Add Java FLAC extractor ([#6406](https://github.com/google/ExoPlayer/issues/6406)). - This extractor does not support seeking and live streams, and does not expose - vorbis, ID3 and picture data. If `DefaultExtractorsFactory` is used, this - extractor is only used if the FLAC extension is not loaded. ### 2.11.1 (2019-12-20) ### @@ -35,6 +32,32 @@ ([#6792](https://github.com/google/ExoPlayer/issues/6792)). ### 2.11.0 (2019-12-11) ### + This extractor does not support seeking and live streams. If + `DefaultExtractorsFactory` is used, this extractor is only used if the FLAC + extension is not loaded. +* Video tunneling: Fix renderer end-of-stream with `OnFrameRenderedListener` + from API 23, tunneled renderer must send a special timestamp on EOS. + Previously the EOS was reported when the input stream reached EOS. +* Require an end time or duration for SubRip (SRT) and SubStation Alpha + (SSA/ASS) subtitles. This applies to both sidecar files & subtitles + [embedded in Matroska streams](https://matroska.org/technical/specs/subtitles/index.html). +* Use `ExoMediaDrm.Provider` in `OfflineLicenseHelper` to avoid `ExoMediaDrm` + leaks ([#4721](https://github.com/google/ExoPlayer/issues/4721)). +* Improve `Format` propagation within the `MediaCodecRenderer` and subclasses. + For example, fix handling of pixel aspect ratio changes in playlists where + video resolution does not change. + ([#6646](https://github.com/google/ExoPlayer/issues/6646)). +* Rename `MediaCodecRenderer.onOutputFormatChanged` to + `MediaCodecRenderer.onOutputMediaFormatChanged`, further + clarifying the distinction between `Format` and `MediaFormat`. +* Fix byte order of HDR10+ static metadata to match CTA-861.3. +* Reconfigure audio sink when PCM encoding changes + ([#6601](https://github.com/google/ExoPlayer/issues/6601)). +* Make `MediaSourceEventListener.LoadEventInfo` and + `MediaSourceEventListener.MediaLoadData` top-level classes. + +### 2.11.0 (not yet released) ### +>>>>>>> b18650fdc... Expose metadata in FLAC extractor * Core library: * Replace `ExoPlayerFactory` by `SimpleExoPlayer.Builder` and diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 02a57dbf81..6ea099064e 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -26,15 +26,13 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; -import com.google.android.exoplayer2.extractor.Id3Peeker; +import com.google.android.exoplayer2.extractor.FlacMetadataReader; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekPoint; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.FlacMetadataReader; import com.google.android.exoplayer2.util.FlacStreamMetadata; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -73,7 +71,6 @@ public final class FlacExtractor implements Extractor { public static final int FLAG_DISABLE_ID3_METADATA = 1; private final ParsableByteArray outputBuffer; - private final Id3Peeker id3Peeker; private final boolean id3MetadataDisabled; @Nullable private FlacDecoderJni decoderJni; @@ -87,7 +84,7 @@ public final class FlacExtractor implements Extractor { @Nullable private Metadata id3Metadata; @Nullable private FlacBinarySearchSeeker binarySearchSeeker; - /** Constructs an instance with flags = 0. */ + /** Constructs an instance with {@code flags = 0}. */ public FlacExtractor() { this(/* flags= */ 0); } @@ -95,11 +92,11 @@ public final class FlacExtractor implements Extractor { /** * Constructs an instance. * - * @param flags Flags that control the extractor's behavior. + * @param flags Flags that control the extractor's behavior. Possible flags are described by + * {@link Flags}. */ public FlacExtractor(int flags) { outputBuffer = new ParsableByteArray(); - id3Peeker = new Id3Peeker(); id3MetadataDisabled = (flags & FLAG_DISABLE_ID3_METADATA) != 0; } @@ -117,7 +114,7 @@ public final class FlacExtractor implements Extractor { @Override public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { - id3Metadata = peekId3Data(input); + id3Metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ !id3MetadataDisabled); return FlacMetadataReader.checkAndPeekStreamMarker(input); } @@ -125,7 +122,7 @@ public final class FlacExtractor implements Extractor { public int read(final ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { if (input.getPosition() == 0 && !id3MetadataDisabled && id3Metadata == null) { - id3Metadata = peekId3Data(input); + id3Metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ true); } FlacDecoderJni decoderJni = initDecoderJni(input); @@ -177,19 +174,6 @@ public final class FlacExtractor implements Extractor { } } - /** - * Peeks ID3 tag data at the beginning of the input. - * - * @return The first ID3 tag {@link Metadata}, or null if an ID3 tag is not present in the input. - */ - @Nullable - private Metadata peekId3Data(ExtractorInput input) throws IOException, InterruptedException { - input.resetPeekPosition(); - Id3Decoder.FramePredicate id3FramePredicate = - id3MetadataDisabled ? Id3Decoder.NO_FRAMES_PREDICATE : null; - return id3Peeker.peekId3Data(input, id3FramePredicate); - } - @EnsuresNonNull({"decoderJni", "extractorOutput", "trackOutput"}) // Ensures initialized. @SuppressWarnings({"contracts.postcondition.not.satisfied"}) private FlacDecoderJni initDecoderJni(ExtractorInput input) { @@ -220,10 +204,7 @@ public final class FlacExtractor implements Extractor { this.streamMetadata = streamMetadata; binarySearchSeeker = outputSeekMap(decoderJni, streamMetadata, input.getLength(), extractorOutput); - Metadata metadata = id3MetadataDisabled ? null : id3Metadata; - if (streamMetadata.metadata != null) { - metadata = streamMetadata.metadata.copyWithAppendedEntriesFrom(metadata); - } + Metadata metadata = streamMetadata.getMetadataCopyWithAppendedEntriesFrom(id3Metadata); outputFormat(streamMetadata, metadata, trackOutput); outputBuffer.reset(streamMetadata.getMaxDecodedFrameSize()); outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.data)); diff --git a/extensions/flac/src/main/jni/flac_jni.cc b/extensions/flac/src/main/jni/flac_jni.cc index f0a33f323c..4fc28ce887 100644 --- a/extensions/flac/src/main/jni/flac_jni.cc +++ b/extensions/flac/src/main/jni/flac_jni.cc @@ -151,7 +151,7 @@ DECODER_FUNC(jobject, flacDecodeMetadata, jlong jContext) { "FlacStreamMetadata"); jmethodID flacStreamMetadataConstructor = env->GetMethodID(flacStreamMetadataClass, "", - "(IIIIIIIJLjava/util/List;Ljava/util/List;)V"); + "(IIIIIIIJLjava/util/ArrayList;Ljava/util/ArrayList;)V"); return env->NewObject(flacStreamMetadataClass, flacStreamMetadataConstructor, streamInfo.min_blocksize, streamInfo.max_blocksize, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java similarity index 97% rename from library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java rename to library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java index 71317494e0..d8d6b8b500 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacFrameReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.util; +package com.google.android.exoplayer2.extractor; + +import com.google.android.exoplayer2.util.FlacStreamMetadata; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; /** Reads and peeks FLAC frame elements. */ public final class FlacFrameReader { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java new file mode 100644 index 0000000000..e86c9b0129 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.VorbisUtil.CommentHeader; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.flac.PictureFrame; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import com.google.android.exoplayer2.util.FlacConstants; +import com.google.android.exoplayer2.util.FlacStreamMetadata; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** Reads and peeks FLAC stream metadata elements from an {@link ExtractorInput}. */ +public final class FlacMetadataReader { + + /** Holds a {@link FlacStreamMetadata}. */ + public static final class FlacStreamMetadataHolder { + /** The FLAC stream metadata. */ + @Nullable public FlacStreamMetadata flacStreamMetadata; + + public FlacStreamMetadataHolder(@Nullable FlacStreamMetadata flacStreamMetadata) { + this.flacStreamMetadata = flacStreamMetadata; + } + } + + /** Holds the metadata extracted from the first frame. */ + public static final class FirstFrameMetadata { + /** The frame start marker, which should correspond to the 2 first bytes of each frame. */ + public final int frameStartMarker; + /** The block size in samples. */ + public final int blockSizeSamples; + + public FirstFrameMetadata(int frameStartMarker, int blockSizeSamples) { + this.frameStartMarker = frameStartMarker; + this.blockSizeSamples = blockSizeSamples; + } + } + + private static final int STREAM_MARKER = 0x664C6143; // ASCII for "fLaC" + private static final int SYNC_CODE = 0x3FFE; + private static final int STREAM_INFO_TYPE = 0; + private static final int VORBIS_COMMENT_TYPE = 4; + private static final int PICTURE_TYPE = 6; + + /** + * Peeks ID3 Data. + * + * @param input Input stream to peek the ID3 data from. + * @param parseData Whether to parse the ID3 frames. + * @return The parsed ID3 data, or {@code null} if there is no such data or if {@code parseData} + * is {@code false}. + * @throws IOException If peeking from the input fails. In this case, there is no guarantee on the + * peek position. + * @throws InterruptedException If interrupted while peeking from input. In this case, there is no + * guarantee on the peek position. + */ + @Nullable + public static Metadata peekId3Metadata(ExtractorInput input, boolean parseData) + throws IOException, InterruptedException { + @Nullable + Id3Decoder.FramePredicate id3FramePredicate = parseData ? null : Id3Decoder.NO_FRAMES_PREDICATE; + return new Id3Peeker().peekId3Data(input, id3FramePredicate); + } + + /** + * Peeks the FLAC stream marker. + * + * @param input Input stream to peek the stream marker from. + * @return Whether the data peeked is the FLAC stream marker. + * @throws IOException If peeking from the input fails. In this case, the peek position is left + * unchanged. + * @throws InterruptedException If interrupted while peeking from input. In this case, the peek + * position is left unchanged. + */ + public static boolean checkAndPeekStreamMarker(ExtractorInput input) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); + input.peekFully(scratch.data, 0, FlacConstants.STREAM_MARKER_SIZE); + return scratch.readUnsignedInt() == STREAM_MARKER; + } + + /** + * Reads ID3 Data. + * + *

    If no exception is thrown, the peek position of {@code input} is aligned with the read + * position. + * + * @param input Input stream to read the ID3 data from. + * @param parseData Whether to parse the ID3 frames. + * @return The parsed ID3 data, or {@code null} if there is no such data or if {@code parseData} + * is {@code false}. + * @throws IOException If reading from the input fails. In this case, the read position is left + * unchanged and there is no guarantee on the peek position. + * @throws InterruptedException If interrupted while reading from input. In this case, the read + * position is left unchanged and there is no guarantee on the peek position. + */ + @Nullable + public static Metadata readId3Metadata(ExtractorInput input, boolean parseData) + throws IOException, InterruptedException { + input.resetPeekPosition(); + long startingPeekPosition = input.getPeekPosition(); + @Nullable Metadata id3Metadata = peekId3Metadata(input, parseData); + int peekedId3Bytes = (int) (input.getPeekPosition() - startingPeekPosition); + input.skipFully(peekedId3Bytes); + return id3Metadata; + } + + /** + * Reads the FLAC stream marker. + * + * @param input Input stream to read the stream marker from. + * @throws ParserException If an error occurs parsing the stream marker. In this case, the + * position of {@code input} is advanced by {@link FlacConstants#STREAM_MARKER_SIZE} bytes. + * @throws IOException If reading from the input fails. In this case, the position is left + * unchanged. + * @throws InterruptedException If interrupted while reading from input. In this case, the + * position is left unchanged. + */ + public static void readStreamMarker(ExtractorInput input) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); + input.readFully(scratch.data, 0, FlacConstants.STREAM_MARKER_SIZE); + if (scratch.readUnsignedInt() != STREAM_MARKER) { + throw new ParserException("Failed to read FLAC stream marker."); + } + } + + /** + * Reads one FLAC metadata block. + * + *

    If no exception is thrown, the peek position of {@code input} is aligned with the read + * position. + * + * @param input Input stream to read the metadata block from (header included). + * @param metadataHolder A holder for the metadata read. If the stream info block (which must be + * the first metadata block) is read, the holder contains a new instance representing the + * stream info data. If the block read is a Vorbis comment block or a picture block, the + * holder contains a copy of the existing stream metadata with the corresponding metadata + * added. Otherwise, the metadata in the holder is unchanged. + * @return Whether the block read is the last metadata block. + * @throws IllegalArgumentException If the block read is not a stream info block and the metadata + * in {@code metadataHolder} is {@code null}. In this case, the read position will be at the + * start of a metadata block and there is no guarantee on the peek position. + * @throws IOException If reading from the input fails. In this case, the read position will be at + * the start of a metadata block and there is no guarantee on the peek position. + * @throws InterruptedException If interrupted while reading from input. In this case, the read + * position will be at the start of a metadata block and there is no guarantee on the peek + * position. + */ + public static boolean readMetadataBlock( + ExtractorInput input, FlacStreamMetadataHolder metadataHolder) + throws IOException, InterruptedException { + input.resetPeekPosition(); + ParsableBitArray scratch = new ParsableBitArray(new byte[4]); + input.peekFully(scratch.data, 0, FlacConstants.METADATA_BLOCK_HEADER_SIZE); + + boolean isLastMetadataBlock = scratch.readBit(); + int type = scratch.readBits(7); + int length = FlacConstants.METADATA_BLOCK_HEADER_SIZE + scratch.readBits(24); + if (type == STREAM_INFO_TYPE) { + metadataHolder.flacStreamMetadata = readStreamInfoBlock(input); + } else { + FlacStreamMetadata flacStreamMetadata = metadataHolder.flacStreamMetadata; + if (flacStreamMetadata == null) { + throw new IllegalArgumentException(); + } + if (type == VORBIS_COMMENT_TYPE) { + List vorbisComments = readVorbisCommentMetadataBlock(input, length); + metadataHolder.flacStreamMetadata = + flacStreamMetadata.copyWithVorbisComments(vorbisComments); + } else if (type == PICTURE_TYPE) { + PictureFrame pictureFrame = readPictureMetadataBlock(input, length); + metadataHolder.flacStreamMetadata = + flacStreamMetadata.copyWithPictureFrames(Collections.singletonList(pictureFrame)); + } else { + input.skipFully(length); + } + } + + return isLastMetadataBlock; + } + + /** + * Returns some metadata extracted from the first frame of a FLAC stream. + * + *

    The data provided may not contain the Vorbis metadata common header and the framing bit. + * + * @see Vorbis + * spec/Comment header + * @param headerData A {@link ParsableByteArray} wrapping the header data. + * @param hasMetadataHeader Whether the {@code headerData} contains a Vorbis metadata common + * header preceding the comment header. + * @param hasFramingBit Whether the {@code headerData} contains a framing bit. + * @return A {@link VorbisUtil.CommentHeader} with all the comments. + * @throws ParserException If an error occurs parsing the comment header. + */ + public static CommentHeader readVorbisCommentHeader( + ParsableByteArray headerData, boolean hasMetadataHeader, boolean hasFramingBit) + throws ParserException { + + if (hasMetadataHeader) { + verifyVorbisHeaderCapturePattern(/* headerType= */ 0x03, headerData, /* quiet= */ false); + } int length = 7; int len = (int) headerData.readLittleEndianUnsignedInt(); @@ -106,7 +199,7 @@ import java.util.Arrays; comments[i] = headerData.readString(len); length += comments[i].length(); } - if ((headerData.readUnsignedByte() & 0x01) == 0) { + if (hasFramingBit && (headerData.readUnsignedByte() & 0x01) == 0) { throw new ParserException("framing bit expected to be set"); } length += 1; @@ -114,8 +207,8 @@ import java.util.Arrays; } /** - * Verifies whether the next bytes in {@code header} are a vorbis header of the given - * {@code headerType}. + * Verifies whether the next bytes in {@code header} are a Vorbis header of the given {@code + * headerType}. * * @param headerType the type of the header expected. * @param header the alleged header bytes. @@ -123,9 +216,8 @@ import java.util.Arrays; * @return the number of bytes read. * @throws ParserException thrown if header type or capture pattern is not as expected. */ - public static boolean verifyVorbisHeaderCapturePattern(int headerType, ParsableByteArray header, - boolean quiet) - throws ParserException { + public static boolean verifyVorbisHeaderCapturePattern( + int headerType, ParsableByteArray header, boolean quiet) throws ParserException { if (header.bytesLeft() < 7) { if (quiet) { return false; @@ -158,12 +250,12 @@ import java.util.Arrays; } /** - * This method reads the modes which are located at the very end of the vorbis setup header. - * That's why we need to partially decode or at least read the entire setup header to know - * where to start reading the modes. + * This method reads the modes which are located at the very end of the Vorbis setup header. + * That's why we need to partially decode or at least read the entire setup header to know where + * to start reading the modes. * - * @see - * Vorbis spec/Setup header + * @see Vorbis + * spec/Setup header * @param headerData a {@link ParsableByteArray} containing setup header data. * @param channels the number of channels. * @return an array of {@link Mode}s. @@ -409,7 +501,7 @@ import java.util.Arrays; // Prevent instantiation. } - public static final class CodeBook { + private static final class CodeBook { public final int dimensions; public final int entries; @@ -427,69 +519,4 @@ import java.util.Arrays; } } - - public static final class CommentHeader { - - public final String vendor; - public final String[] comments; - public final int length; - - public CommentHeader(String vendor, String[] comments, int length) { - this.vendor = vendor; - this.comments = comments; - this.length = length; - } - - } - - public static final class VorbisIdHeader { - - public final long version; - public final int channels; - public final long sampleRate; - public final int bitrateMax; - public final int bitrateNominal; - public final int bitrateMin; - public final int blockSize0; - public final int blockSize1; - public final boolean framingFlag; - public final byte[] data; - - public VorbisIdHeader(long version, int channels, long sampleRate, int bitrateMax, - int bitrateNominal, int bitrateMin, int blockSize0, int blockSize1, boolean framingFlag, - byte[] data) { - this.version = version; - this.channels = channels; - this.sampleRate = sampleRate; - this.bitrateMax = bitrateMax; - this.bitrateNominal = bitrateNominal; - this.bitrateMin = bitrateMin; - this.blockSize0 = blockSize0; - this.blockSize1 = blockSize1; - this.framingFlag = framingFlag; - this.data = data; - } - - public int getApproximateBitrate() { - return bitrateNominal == 0 ? (bitrateMin + bitrateMax) / 2 : bitrateNominal; - } - - } - - public static final class Mode { - - public final boolean blockFlag; - public final int windowType; - public final int transformType; - public final int mapping; - - public Mode(boolean blockFlag, int windowType, int transformType, int mapping) { - this.blockFlag = blockFlag; - this.windowType = windowType; - this.transformType = transformType; - this.mapping = mapping; - } - - } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java index 1ca010dbb1..0f67153e61 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -18,20 +18,22 @@ package com.google.android.exoplayer2.extractor.flac; import static com.google.android.exoplayer2.util.Util.castNonNull; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.FlacFrameReader; +import com.google.android.exoplayer2.extractor.FlacFrameReader.BlockSizeHolder; +import com.google.android.exoplayer2.extractor.FlacMetadataReader; +import com.google.android.exoplayer2.extractor.FlacMetadataReader.FirstFrameMetadata; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.FlacConstants; -import com.google.android.exoplayer2.util.FlacFrameReader; -import com.google.android.exoplayer2.util.FlacFrameReader.BlockSizeHolder; -import com.google.android.exoplayer2.util.FlacMetadataReader; -import com.google.android.exoplayer2.util.FlacMetadataReader.FirstFrameMetadata; import com.google.android.exoplayer2.util.FlacStreamMetadata; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; @@ -41,7 +43,6 @@ import java.lang.annotation.RetentionPolicy; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // TODO: implement seeking. -// TODO: expose vorbis and ID3 data. // TODO: support live streams. /** * Extracts data from FLAC container format. @@ -53,23 +54,40 @@ public final class FlacExtractor implements Extractor { /** Factory for {@link FlacExtractor} instances. */ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_DISABLE_ID3_METADATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_ID3_METADATA}) + public @interface Flags {} + + /** + * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not + * required. + */ + public static final int FLAG_DISABLE_ID3_METADATA = 1; + /** Parser state. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef({ - STATE_READ_ID3_TAG, + STATE_READ_ID3_METADATA, + STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES, STATE_READ_STREAM_MARKER, - STATE_READ_STREAM_INFO_BLOCK, - STATE_SKIP_OPTIONAL_METADATA_BLOCKS, + STATE_READ_METADATA_BLOCKS, STATE_GET_FIRST_FRAME_METADATA, STATE_READ_FRAMES }) private @interface State {} - private static final int STATE_READ_ID3_TAG = 0; - private static final int STATE_READ_STREAM_MARKER = 1; - private static final int STATE_READ_STREAM_INFO_BLOCK = 2; - private static final int STATE_SKIP_OPTIONAL_METADATA_BLOCKS = 3; + private static final int STATE_READ_ID3_METADATA = 0; + private static final int STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES = 1; + private static final int STATE_READ_STREAM_MARKER = 2; + private static final int STATE_READ_METADATA_BLOCKS = 3; private static final int STATE_GET_FIRST_FRAME_METADATA = 4; private static final int STATE_READ_FRAMES = 5; @@ -81,6 +99,7 @@ public final class FlacExtractor implements Extractor { private final byte[] streamMarkerAndInfoBlock; private final ParsableByteArray scratch; + private final boolean id3MetadataDisabled; private final BlockSizeHolder blockSizeHolder; @@ -88,6 +107,7 @@ public final class FlacExtractor implements Extractor { @MonotonicNonNull private TrackOutput trackOutput; private @State int state; + @Nullable private Metadata id3Metadata; @MonotonicNonNull private FlacStreamMetadata flacStreamMetadata; private int minFrameSize; private int frameStartMarker; @@ -95,16 +115,28 @@ public final class FlacExtractor implements Extractor { private int currentFrameBytesWritten; private long totalSamplesWritten; + /** Constructs an instance with {@code flags = 0}. */ public FlacExtractor() { + this(/* flags= */ 0); + } + + /** + * Constructs an instance. + * + * @param flags Flags that control the extractor's behavior. Possible flags are described by + * {@link Flags}. + */ + public FlacExtractor(int flags) { streamMarkerAndInfoBlock = new byte[FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE]; scratch = new ParsableByteArray(SCRATCH_LENGTH); blockSizeHolder = new BlockSizeHolder(); + id3MetadataDisabled = (flags & FLAG_DISABLE_ID3_METADATA) != 0; } @Override public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { - FlacMetadataReader.peekId3Data(input); + FlacMetadataReader.peekId3Metadata(input, /* parseData= */ false); return FlacMetadataReader.checkAndPeekStreamMarker(input); } @@ -119,17 +151,17 @@ public final class FlacExtractor implements Extractor { public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { switch (state) { - case STATE_READ_ID3_TAG: - readId3Tag(input); + case STATE_READ_ID3_METADATA: + readId3Metadata(input); + return Extractor.RESULT_CONTINUE; + case STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES: + getStreamMarkerAndInfoBlockBytes(input); return Extractor.RESULT_CONTINUE; case STATE_READ_STREAM_MARKER: readStreamMarker(input); return Extractor.RESULT_CONTINUE; - case STATE_READ_STREAM_INFO_BLOCK: - readStreamInfoBlock(input); - return Extractor.RESULT_CONTINUE; - case STATE_SKIP_OPTIONAL_METADATA_BLOCKS: - skipOptionalMetadataBlocks(input); + case STATE_READ_METADATA_BLOCKS: + readMetadataBlocks(input); return Extractor.RESULT_CONTINUE; case STATE_GET_FIRST_FRAME_METADATA: getFirstFrameMetadata(input); @@ -143,7 +175,7 @@ public final class FlacExtractor implements Extractor { @Override public void seek(long position, long timeUs) { - state = STATE_READ_ID3_TAG; + state = STATE_READ_ID3_METADATA; currentFrameBytesWritten = 0; totalSamplesWritten = 0; scratch.reset(); @@ -156,40 +188,40 @@ public final class FlacExtractor implements Extractor { // Private methods. - private void readId3Tag(ExtractorInput input) throws IOException, InterruptedException { - FlacMetadataReader.readId3Data(input); + private void readId3Metadata(ExtractorInput input) throws IOException, InterruptedException { + id3Metadata = FlacMetadataReader.readId3Metadata(input, /* parseData= */ !id3MetadataDisabled); + state = STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES; + } + + private void getStreamMarkerAndInfoBlockBytes(ExtractorInput input) + throws IOException, InterruptedException { + input.peekFully(streamMarkerAndInfoBlock, 0, streamMarkerAndInfoBlock.length); + input.resetPeekPosition(); state = STATE_READ_STREAM_MARKER; } private void readStreamMarker(ExtractorInput input) throws IOException, InterruptedException { - FlacMetadataReader.readStreamMarker( - input, streamMarkerAndInfoBlock, /* scratchWriteIndex= */ 0); - state = STATE_READ_STREAM_INFO_BLOCK; + FlacMetadataReader.readStreamMarker(input); + state = STATE_READ_METADATA_BLOCKS; } - private void readStreamInfoBlock(ExtractorInput input) throws IOException, InterruptedException { - flacStreamMetadata = - FlacMetadataReader.readStreamInfoBlock( - input, - /* scratchData= */ streamMarkerAndInfoBlock, - /* scratchWriteIndex= */ FlacConstants.STREAM_MARKER_SIZE); + private void readMetadataBlocks(ExtractorInput input) throws IOException, InterruptedException { + boolean isLastMetadataBlock = false; + FlacMetadataReader.FlacStreamMetadataHolder metadataHolder = + new FlacMetadataReader.FlacStreamMetadataHolder(flacStreamMetadata); + while (!isLastMetadataBlock) { + isLastMetadataBlock = FlacMetadataReader.readMetadataBlock(input, metadataHolder); + // Save the current metadata in case an exception occurs. + flacStreamMetadata = castNonNull(metadataHolder.flacStreamMetadata); + } + + Assertions.checkNotNull(flacStreamMetadata); minFrameSize = Math.max(flacStreamMetadata.minFrameSize, FlacConstants.MIN_FRAME_HEADER_SIZE); - boolean isLastMetadataBlock = - (streamMarkerAndInfoBlock[FlacConstants.STREAM_MARKER_SIZE] >> 7 & 1) == 1; - castNonNull(trackOutput).format(flacStreamMetadata.getFormat(streamMarkerAndInfoBlock)); + castNonNull(trackOutput) + .format(flacStreamMetadata.getFormat(streamMarkerAndInfoBlock, id3Metadata)); castNonNull(extractorOutput) .seekMap(new SeekMap.Unseekable(flacStreamMetadata.getDurationUs())); - if (isLastMetadataBlock) { - state = STATE_GET_FIRST_FRAME_METADATA; - } else { - state = STATE_SKIP_OPTIONAL_METADATA_BLOCKS; - } - } - - private void skipOptionalMetadataBlocks(ExtractorInput input) - throws IOException, InterruptedException { - FlacMetadataReader.skipMetadataBlocks(input); state = STATE_GET_FIRST_FRAME_METADATA; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java index ed86944f1e..152d803da7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java @@ -69,7 +69,7 @@ import java.util.Arrays; if (streamMetadata == null) { streamMetadata = new FlacStreamMetadata(data, 17); byte[] metadata = Arrays.copyOfRange(data, 9, packet.limit()); - setupData.format = streamMetadata.getFormat(metadata); + setupData.format = streamMetadata.getFormat(metadata, /* id3Metadata= */ null); } else if ((data[0] & 0x7F) == SEEKTABLE_PACKET_TYPE) { flacOggSeeker = new FlacOggSeeker(); flacOggSeeker.parseSeekTable(packet); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java index 2675edd5b1..b57678266a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java @@ -18,7 +18,8 @@ package com.google.android.exoplayer2.extractor.ogg; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.ParserException; -import com.google.android.exoplayer2.extractor.ogg.VorbisUtil.Mode; +import com.google.android.exoplayer2.extractor.VorbisUtil; +import com.google.android.exoplayer2.extractor.VorbisUtil.Mode; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java deleted file mode 100644 index 23eefd042c..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacMetadataReader.java +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.util; - -import com.google.android.exoplayer2.ParserException; -import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.Id3Peeker; -import com.google.android.exoplayer2.metadata.id3.Id3Decoder; -import java.io.IOException; - -/** Reads and peeks FLAC stream metadata elements from an {@link ExtractorInput}. */ -public final class FlacMetadataReader { - - /** Holds the metadata extracted from the first frame. */ - public static final class FirstFrameMetadata { - /** The frame start marker, which should correspond to the 2 first bytes of each frame. */ - public final int frameStartMarker; - /** The block size in samples. */ - public final int blockSizeSamples; - - public FirstFrameMetadata(int frameStartMarker, int blockSizeSamples) { - this.frameStartMarker = frameStartMarker; - this.blockSizeSamples = blockSizeSamples; - } - } - - private static final int STREAM_MARKER = 0x664C6143; // ASCII for "fLaC" - private static final int SYNC_CODE = 0x3FFE; - - /** - * Peeks ID3 Data. - * - * @param input Input stream to peek the ID3 data from. - * @throws IOException If peeking from the input fails. In this case, there is no guarantee on the - * peek position. - * @throws InterruptedException If interrupted while peeking from input. In this case, there is no - * guarantee on the peek position. - */ - public static void peekId3Data(ExtractorInput input) throws IOException, InterruptedException { - new Id3Peeker().peekId3Data(input, Id3Decoder.NO_FRAMES_PREDICATE); - } - - /** - * Peeks the FLAC stream marker. - * - * @param input Input stream to peek the stream marker from. - * @return Whether the data peeked is the FLAC stream marker. - * @throws IOException If peeking from the input fails. In this case, the peek position is left - * unchanged. - * @throws InterruptedException If interrupted while peeking from input. In this case, the peek - * position is left unchanged. - */ - public static boolean checkAndPeekStreamMarker(ExtractorInput input) - throws IOException, InterruptedException { - ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); - input.peekFully(scratch.data, /* offset= */ 0, FlacConstants.STREAM_MARKER_SIZE); - return scratch.readUnsignedInt() == STREAM_MARKER; - } - - /** - * Reads ID3 Data. - * - *

    If no exception is thrown, the peek position of {@code input} is aligned with the read - * position. - * - * @param input Input stream to read the ID3 data from. - * @throws IOException If reading from the input fails. In this case, the read position is left - * unchanged and there is no guarantee on the peek position. - * @throws InterruptedException If interrupted while reading from input. In this case, the read - * position is left unchanged and there is no guarantee on the peek position. - */ - public static void readId3Data(ExtractorInput input) throws IOException, InterruptedException { - input.resetPeekPosition(); - long startingPeekPosition = input.getPeekPosition(); - new Id3Peeker().peekId3Data(input, Id3Decoder.NO_FRAMES_PREDICATE); - int peekedId3Bytes = (int) (input.getPeekPosition() - startingPeekPosition); - input.skipFully(peekedId3Bytes); - } - - /** - * Reads the FLAC stream marker. - * - * @param input Input stream to read the stream marker from. - * @param scratchData The array in which the data read should be copied. This array must have size - * at least {@code scratchWriteIndex} + {@link FlacConstants#STREAM_MARKER_SIZE}. - * @param scratchWriteIndex The index of {@code scratchData} from which to write. - * @throws ParserException If an error occurs parsing the stream marker. In this case, the - * position of {@code input} is advanced by {@link FlacConstants#STREAM_MARKER_SIZE} bytes. - * @throws IOException If reading from the input fails. In this case, the position is left - * unchanged. - * @throws InterruptedException If interrupted while reading from input. In this case, the - * position is left unchanged. - */ - public static void readStreamMarker( - ExtractorInput input, byte[] scratchData, int scratchWriteIndex) - throws IOException, InterruptedException { - ParsableByteArray scratch = new ParsableByteArray(scratchData); - input.readFully( - scratch.data, - /* offset= */ scratchWriteIndex, - /* length= */ FlacConstants.STREAM_MARKER_SIZE); - scratch.setPosition(scratchWriteIndex); - if (scratch.readUnsignedInt() != STREAM_MARKER) { - throw new ParserException("Failed to read FLAC stream marker."); - } - } - - /** - * Reads the stream info block. - * - * @param input Input stream to read the stream info block from. - * @param scratchData The array in which the data read should be copied. This array must have size - * at least {@code scratchWriteIndex} + {@link FlacConstants#STREAM_INFO_BLOCK_SIZE}. - * @param scratchWriteIndex The index of {@code scratchData} from which to write. - * @return A new {@link FlacStreamMetadata} read from {@code input}. - * @throws IOException If reading from the input fails. In this case, the position is left - * unchanged. - * @throws InterruptedException If interrupted while reading from input. In this case, the - * position is left unchanged. - */ - public static FlacStreamMetadata readStreamInfoBlock( - ExtractorInput input, byte[] scratchData, int scratchWriteIndex) - throws IOException, InterruptedException { - input.readFully( - scratchData, - /* offset= */ scratchWriteIndex, - /* length= */ FlacConstants.STREAM_INFO_BLOCK_SIZE); - return new FlacStreamMetadata( - scratchData, /* offset= */ scratchWriteIndex + FlacConstants.METADATA_BLOCK_HEADER_SIZE); - } - - /** - * Skips the stream metadata blocks. - * - *

    If no exception is thrown, the peek position of {@code input} is aligned with the read - * position. - * - * @param input Input stream to read the metadata blocks from. - * @throws IOException If reading from the input fails. In this case, the read position will be at - * the start of a metadata block and there is no guarantee on the peek position. - * @throws InterruptedException If interrupted while reading from input. In this case, the read - * position will be at the start of a metadata block and there is no guarantee on the peek - * position. - */ - public static void skipMetadataBlocks(ExtractorInput input) - throws IOException, InterruptedException { - input.resetPeekPosition(); - ParsableBitArray scratch = new ParsableBitArray(new byte[4]); - boolean lastMetadataBlock = false; - while (!lastMetadataBlock) { - input.peekFully(scratch.data, /* offset= */ 0, /* length= */ 4); - scratch.setPosition(0); - lastMetadataBlock = scratch.readBit(); - scratch.skipBits(7); - int length = scratch.readBits(24); - input.skipFully(4 + length); - } - } - - /** - * Returns some metadata extracted from the first frame of a FLAC stream. - * - *

    The read position of {@code input} is left unchanged and the peek position is aligned with - * the read position. - * - * @param input Input stream to get the metadata from (starting from the read position). - * @return A {@link FirstFrameMetadata} containing the frame start marker (which should be the - * same for all the frames in the stream) and the block size of the frame. - * @throws ParserException If an error occurs parsing the frame metadata. - * @throws IOException If peeking from the input fails. - * @throws InterruptedException If interrupted while peeking from input. - */ - public static FirstFrameMetadata getFirstFrameMetadata(ExtractorInput input) - throws IOException, InterruptedException { - input.resetPeekPosition(); - ParsableByteArray scratch = - new ParsableByteArray(new byte[FlacConstants.MAX_FRAME_HEADER_SIZE]); - input.peekFully(scratch.data, /* offset= */ 0, FlacConstants.MAX_FRAME_HEADER_SIZE); - - int frameStartMarker = scratch.readUnsignedShort(); - int syncCode = frameStartMarker >> 2; - if (syncCode != SYNC_CODE) { - input.resetPeekPosition(); - throw new ParserException("First frame does not start with sync code."); - } - - scratch.setPosition(0); - int firstFrameBlockSizeSamples = FlacFrameReader.getFrameBlockSizeSamples(scratch); - - input.resetPeekPosition(); - return new FirstFrameMetadata(frameStartMarker, firstFrameBlockSizeSamples); - } - - private FlacMetadataReader() {} -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java index b35d585a05..2772f7e0c6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java @@ -25,13 +25,24 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -/** Holder for FLAC metadata. */ +/** + * Holder for FLAC metadata. + * + * @see FLAC format + * METADATA_BLOCK_STREAMINFO + * @see FLAC format + * METADATA_BLOCK_VORBIS_COMMENT + * @see FLAC format + * METADATA_BLOCK_PICTURE + */ public final class FlacStreamMetadata { private static final String TAG = "FlacStreamMetadata"; /** Indicates that a value is not in the corresponding lookup table. */ public static final int NOT_IN_LOOKUP_TABLE = -1; + /** Separator between the field name of a Vorbis comment and the corresponding value. */ + private static final String SEPARATOR = "="; /** Minimum number of samples per block. */ public final int minBlockSizeSamples; @@ -68,53 +79,33 @@ public final class FlacStreamMetadata { public final int bitsPerSampleLookupKey; /** Total number of samples, or 0 if the value is unknown. */ public final long totalSamples; - /** Stream content metadata. */ - @Nullable public final Metadata metadata; - private static final String SEPARATOR = "="; + /** Content metadata. */ + private final Metadata metadata; /** * Parses binary FLAC stream info metadata. * - * @param data An array containing binary FLAC stream info metadata. + * @param data An array containing binary FLAC stream info block (with or without header). * @param offset The offset of the stream info block in {@code data} (header excluded). - * @see FLAC format - * METADATA_BLOCK_STREAMINFO */ public FlacStreamMetadata(byte[] data, int offset) { ParsableBitArray scratch = new ParsableBitArray(data); scratch.setPosition(offset * 8); - this.minBlockSizeSamples = scratch.readBits(16); - this.maxBlockSizeSamples = scratch.readBits(16); - this.minFrameSize = scratch.readBits(24); - this.maxFrameSize = scratch.readBits(24); - this.sampleRate = scratch.readBits(20); - this.sampleRateLookupKey = getSampleRateLookupKey(); - this.channels = scratch.readBits(3) + 1; - this.bitsPerSample = scratch.readBits(5) + 1; - this.bitsPerSampleLookupKey = getBitsPerSampleLookupKey(); - this.totalSamples = scratch.readBitsToLong(36); - this.metadata = null; + minBlockSizeSamples = scratch.readBits(16); + maxBlockSizeSamples = scratch.readBits(16); + minFrameSize = scratch.readBits(24); + maxFrameSize = scratch.readBits(24); + sampleRate = scratch.readBits(20); + sampleRateLookupKey = getSampleRateLookupKey(sampleRate); + channels = scratch.readBits(3) + 1; + bitsPerSample = scratch.readBits(5) + 1; + bitsPerSampleLookupKey = getBitsPerSampleLookupKey(bitsPerSample); + totalSamples = scratch.readBitsToLong(36); + metadata = new Metadata(); } - /** - * @param minBlockSizeSamples Minimum block size of the FLAC stream. - * @param maxBlockSizeSamples Maximum block size of the FLAC stream. - * @param minFrameSize Minimum frame size of the FLAC stream. - * @param maxFrameSize Maximum frame size of the FLAC stream. - * @param sampleRate Sample rate of the FLAC stream. - * @param channels Number of channels of the FLAC stream. - * @param bitsPerSample Number of bits per sample of the FLAC stream. - * @param totalSamples Total samples of the FLAC stream. - * @param vorbisComments Vorbis comments. Each entry must be in key=value form. - * @param pictureFrames Picture frames. - * @see FLAC format - * METADATA_BLOCK_STREAMINFO - * @see FLAC format - * METADATA_BLOCK_VORBIS_COMMENT - * @see FLAC format - * METADATA_BLOCK_PICTURE - */ + // Used in native code. public FlacStreamMetadata( int minBlockSizeSamples, int maxBlockSizeSamples, @@ -124,19 +115,41 @@ public final class FlacStreamMetadata { int channels, int bitsPerSample, long totalSamples, - List vorbisComments, - List pictureFrames) { + ArrayList vorbisComments, + ArrayList pictureFrames) { + this( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + buildMetadata(vorbisComments, pictureFrames)); + } + + private FlacStreamMetadata( + int minBlockSizeSamples, + int maxBlockSizeSamples, + int minFrameSize, + int maxFrameSize, + int sampleRate, + int channels, + int bitsPerSample, + long totalSamples, + Metadata metadata) { this.minBlockSizeSamples = minBlockSizeSamples; this.maxBlockSizeSamples = maxBlockSizeSamples; this.minFrameSize = minFrameSize; this.maxFrameSize = maxFrameSize; this.sampleRate = sampleRate; - this.sampleRateLookupKey = getSampleRateLookupKey(); + this.sampleRateLookupKey = getSampleRateLookupKey(sampleRate); this.channels = channels; this.bitsPerSample = bitsPerSample; - this.bitsPerSampleLookupKey = getBitsPerSampleLookupKey(); + this.bitsPerSampleLookupKey = getBitsPerSampleLookupKey(bitsPerSample); this.totalSamples = totalSamples; - this.metadata = getMetadata(vorbisComments, pictureFrames); + this.metadata = metadata; } /** Returns the maximum size for a decoded frame from the FLAC stream. */ @@ -193,12 +206,15 @@ public final class FlacStreamMetadata { * * @param streamMarkerAndInfoBlock An array containing the FLAC stream marker followed by the * stream info block. + * @param id3Metadata The ID3 metadata of the stream, or {@code null} if there is no such data. * @return The extracted {@link Format}. */ - public Format getFormat(byte[] streamMarkerAndInfoBlock) { + public Format getFormat(byte[] streamMarkerAndInfoBlock, @Nullable Metadata id3Metadata) { // Set the last metadata block flag, ignore the other blocks. streamMarkerAndInfoBlock[4] = (byte) 0x80; int maxInputSize = maxFrameSize > 0 ? maxFrameSize : Format.NO_VALUE; + Metadata metadataWithId3 = metadata.copyWithAppendedEntriesFrom(id3Metadata); + return Format.createAudioSampleFormat( /* id= */ null, MimeTypes.AUDIO_FLAC, @@ -207,13 +223,55 @@ public final class FlacStreamMetadata { maxInputSize, channels, sampleRate, - Collections.singletonList(streamMarkerAndInfoBlock), + /* pcmEncoding= */ Format.NO_VALUE, + /* encoderDelay= */ 0, + /* encoderPadding= */ 0, + /* initializationData= */ Collections.singletonList(streamMarkerAndInfoBlock), /* drmInitData= */ null, /* selectionFlags= */ 0, - /* language= */ null); + /* language= */ null, + metadataWithId3); } - private int getSampleRateLookupKey() { + /** Returns a copy of the content metadata with entries from {@code other} appended. */ + public Metadata getMetadataCopyWithAppendedEntriesFrom(@Nullable Metadata other) { + return metadata.copyWithAppendedEntriesFrom(other); + } + + /** Returns a copy of {@code this} with the given Vorbis comments added to the metadata. */ + public FlacStreamMetadata copyWithVorbisComments(List vorbisComments) { + Metadata appendedMetadata = + metadata.copyWithAppendedEntriesFrom( + buildMetadata(vorbisComments, Collections.emptyList())); + return new FlacStreamMetadata( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + appendedMetadata); + } + + /** Returns a copy of {@code this} with the given picture frames added to the metadata. */ + public FlacStreamMetadata copyWithPictureFrames(List pictureFrames) { + Metadata appendedMetadata = + metadata.copyWithAppendedEntriesFrom(buildMetadata(Collections.emptyList(), pictureFrames)); + return new FlacStreamMetadata( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + appendedMetadata); + } + + private static int getSampleRateLookupKey(int sampleRate) { switch (sampleRate) { case 88200: return 1; @@ -242,7 +300,7 @@ public final class FlacStreamMetadata { } } - private int getBitsPerSampleLookupKey() { + private static int getBitsPerSampleLookupKey(int bitsPerSample) { switch (bitsPerSample) { case 8: return 1; @@ -259,11 +317,10 @@ public final class FlacStreamMetadata { } } - @Nullable - private static Metadata getMetadata( + private static Metadata buildMetadata( List vorbisComments, List pictureFrames) { if (vorbisComments.isEmpty() && pictureFrames.isEmpty()) { - return null; + return new Metadata(); } ArrayList metadataEntries = new ArrayList<>(); @@ -271,7 +328,7 @@ public final class FlacStreamMetadata { String vorbisComment = vorbisComments.get(i); String[] keyAndValue = Util.splitAtFirst(vorbisComment, SEPARATOR); if (keyAndValue.length != 2) { - Log.w(TAG, "Failed to parse vorbis comment: " + vorbisComment); + Log.w(TAG, "Failed to parse Vorbis comment: " + vorbisComment); } else { VorbisComment entry = new VorbisComment(keyAndValue[0], keyAndValue[1]); metadataEntries.add(entry); @@ -279,6 +336,6 @@ public final class FlacStreamMetadata { } metadataEntries.addAll(pictureFrames); - return metadataEntries.isEmpty() ? null : new Metadata(metadataEntries); + return metadataEntries.isEmpty() ? new Metadata() : new Metadata(metadataEntries); } } diff --git a/library/core/src/test/assets/binary/ogg/vorbis_header_pages b/library/core/src/test/assets/binary/ogg/vorbis_header_pages new file mode 100644 index 0000000000000000000000000000000000000000..afa13aa80315301b5b654b3fbb7ab6dbc10ca3a6 GIT binary patch literal 3743 zcmeHKeNa=`6+a090s;;A$OboT6AhS$;1CgN;89ov;D0ZP6*L9{dxHEu)FG1xZBmtM+PuUr{Xb^U7-uZ_TVLlT?)#=d$W0ddarrFd!<(i5?oS!us4t##M<_6ddYE)y*{75kq_lqJ#N0O;wJ+dh#;2sVs_!VLgHN-dRfn%Xlgo|=<|^TYE4iAVV; zr#tD^**tk|hswL-f7vJR>`S;fQa(A7I5o0zZ7aK62asi=#%icwc3lYozesND-`Vyc z&*{nU4sJeqi3?T$9?FGf3{%F>WHXPBu!!49`~Gi zW7kc$^Nn4Tvi>%vlJ-&Adxswi%|pJ$F3WFIX|r81F8XTN#JT|wEC`Azao`4J$5qg-nw6;&euqQ76lA%5FLn^=H&mza!l^uQf*JEaX(*eO~?M^XiWlN)PD9P&POY4hX>7zti@q4&%`lm+y=(Z!c?T zKi|-K;qBL05ey7^4V$%(4Vx^#eW1MiJPG#Tpl!fAI9Sm+Q2xI=w^w=*AOs+GqE?(y zE1sw&r(!)U2>=N|nn|2qmpI{6?!hzO(atOmk5I=h2}#bl7%>w;n@8f16gK((!^6{VivPazY4}@V5CUh z`L4~O4u7hRoYh9YP=toGraVzke8MBIiI>>0vL%0vrMO5FBUhHBgJ3fh)rhCC z`er!E-4^R_D*1Ba1o-SV(CM zfD~(ZXVj?xYQsnrv~nR?Zo~S!oQhSC>Mie#w>?$QtWQ7My?icSJGZ)bec$7Tfsi{? z`W;^1;Z8h7s2E=*RkN-5RU3SHzE(>EAC*X*VGZVn%x{X6qmVx$Irc>n}SCnR1;$jSc>aqwz0TOBu z*t}yZv79*F!@g_006`POiTHxT2<>s=_Cw!j~Nba^kkFd$TFy9dzT!5b^9t_`Mx|PqQvu8LdZ;Ru=*sJrMv| zjo}wJby2?#6q1}l@FwA>>gW~+6Dsvk8Ux*oIUGjrR7X}&Xn1}IxG_)G5Dp7-zSY1& z$p3%f!tfR`ytT0^FZRCsaN;{`R^o1o?2769OTNW|EgqLMs}iwOC!(!pA;C=@3O?;) z5tLn(k;G!DE4hX$7F7-VnBp~jB|Pwgy7D!+wFbQY)3Y^L=2!J4N=t;%2iG}}aD6-` z*Et=U(X@`Z=|K>{xO}X`Je|t#bwwHUL-o@tz7#V+?|s)*Jc-$p%Ezq0sb^lrrUs^p ztDs`Yg5LF@98TvMc0~d*n93oKK!bUN9Ap%n>UB8`bR07DNxt@igkIM^!azB%FV~D5y-jvQ&Wh74>;hfo<3~k9+pb7gQu&5gx$pE*4bK9@fi3 zbESf)_j(Y!N01_8zwoZ$Cx5v5IbUqLk<5uExksR#qui;|dn4mjV?C}%UD>!jrMA7q zQJfWf&aTp{(%cHSH^{I5ZmpRt$gW;BatPajkc`+arf2x%@NfS zwq{Gp$k6jIN>|9qObP>wDtIaw85kNF7#SEVX!z&mWfl~r7N_Qw zXfgwpa{;lVk5j0x?cpVdw+RDzA)X;VuC|T}#fiBEIjIW8`FZKQAVonTp1~ou?)mxY HIjM{QIG-Rr literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/binary/vorbis/id_header b/library/core/src/test/assets/binary/vorbis/id_header new file mode 100644 index 0000000000000000000000000000000000000000..556b4acfbf9db3d342d707873838a77644c7d6a6 GIT binary patch literal 30 fcmZQ9%P&gGEM@=#CZ#Y2hW|j2#li@tS26+smS7Ab literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/binary/vorbis/setup_header b/library/core/src/test/assets/binary/vorbis/setup_header new file mode 100644 index 0000000000000000000000000000000000000000..cafc779059db3bbc16dbb088e27e1a5e1dc7bbfd GIT binary patch literal 3597 zcmeHKeNa=`6+eLh0f7d5WP=U6i3ZGz;20-bW3w|5f+UeF3==%A?ipZJT->aG=!)dBn z(SSI&ZFgu#m~YDe$m~$vYMynK0=>W|*k9D3&06lZ4eXGp9?fTesq}mnB z?uK^RDmSr#q~)54d`JC7K2kmbQkQ7TD*MpgFs(r9vGA8v!^jSr%Rijhu#VOeqV+wc zH9L2FIKL{#2s49|R?kpBlP&XXBeWKr3RZs;%FQX@0_g3NI6jvoayN{{!A$^ec0G}Q zj@UaYn3@wOa1zR+09e35In5cL#^En(KUCe5@r!bG>)&#Uz*r385I~(ln57Fz`HSIo491|QD3%Fn( z*yZ@62IJA5UhdAQ?mXPod9lfR>Ag1@5ey7^4V%5637fdQbFjMSA|33ZA;(}~XsE_J zSpC0yM4#v~KrBF7xL%N7F9_Gur(!%Tj07P-u0fF3kQsK%_u?7vs%KstH0*3F-fvwC z0*cTrY*S*tg|0xNL#e$h*MG}SJ2nC^`CJh*Dl%(NgrKc!VClrO-(jRJN_U&~ud)~O zvY64sp-qQ8154zLKoxI%qCBRPwUJ*@+e5<8k||!~;bZVb&~ga);#ikF0sHF4KwKCp zH?ZY}n8i%fu7Lgp=n2-V`FHtpDHEQnlV;UPuaux6wINEqux1Ob6sEA|N?waC`47+# zn){lq1-82w-UI*51DN9MUHB>Ew4S-bfzR3WGw~W5R<>=Q zVJWUsrAp-HJm7AIs-uD_tiJhf`fdxfcjN%Ga{3~V-@%LbU2T8UU^;N4plTbTk1XSj zkAXLOR&HNrDU+(aG+4-Pj)ZJ`f;ahWB(Y&E8QLOXo5X?jcR2^E9?@6r&2T(d&aBTn z-m`omLp`@ru)hCE(_rjfBJVD{|A-e)5huW>q;A$zCg`1Yori1<*mDe-Q!8o%p)W42 ze8c;=39C95@sV}<6{P_emXzLWU2f_pv7iqPJ^gq^Jts%W(tC{$nouPz`Skj8r2z{s zN}jj0;WNNUPHyMs;jT&>E6bx%aOlt1N4895Ggj_3T2i|PYfv2mgUAB3yT8+t+DmI1 ztGf@l1Qp$nzBWSh@9zYhm0z`Dg^5W-n6YP8yXSn~(vgo_WAF?qtZV|H%1Jp$3%{dC z7n@+BbW*?DP3in4$&@bO(dl&LdAC7F20Nu>z~iCP{XRD(1^CN23qLB1b2_+UAe(!N zFgX>Q{Ur{BL>-iLqRTd|B~wke70W)4Q}(#qM(IK{I3m+cG+^@F!Qpg~j`Z86b0mIG zvT9lAp>%)hwsGSCq(>3hyl*huPMzyz-kJbFba*rqA1I8_UN??!7ZUmfw-~20>-EKt zntCJ-+EY3ruaL-_lb4>)Jll^C>CpA92Y9c1*)=Gk-L|!$n9$!vH;xU{o*hfLzjM{o zj0>NRH=;+al>o<2MM6jkj;HTbS5b6a zLU}CsFi+MH4g+(()xbc=|9^;p(JcaacVk^y+5_K_%=ekB#NAXmWz*$Xf{PVf{2q5v zEn+7xqOBGljhnh;95TotD5oMni@{P?_vz{wR5co;jBEIccia_zPqI!s+&Hb^h%f`Rfe$>! zNz9%c4rT>T{qqVYH852?6*a>)^sXP}a5_&hE2PMvYKQ$?73L9gkWq2A&*RdO>B!J# z1v@LGeV!7UfpUT1KAo0k&~nCF(&MJIWDuvr6H7*N7(ppA7OA@7;1W%BR$%V)4%B4f zioH0c_GX0^E0CI}Y6AEh%5_o5ou_KEa79fPs;WgsMQzsAr3%Eas4svD9HYT`+;ecg zq9*Gq?*MLhv7(0bGhXJIYGt(gfFH4YB3)#hmp)xw_NrG8Uy>a4NKlT%XJ*{Howw*kk18YNW>;in<+tFH^;gMq8Xm z$qB5soCsiJ%JPMhE*3<`a5lw6q_NnERKLEX1@g)EN literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear.flac b/library/core/src/test/assets/flac/bear.flac index 3e17983fef7a7945937f59a43a4254cd1e06e16b..15f90b463e55e80e5e27238fcf77a968ab85e954 100644 GIT binary patch delta 36 scmex=lI#CTt_=-LER75bI-55#{gy3`MDFsYR)I$*BrCnMrOwj?M~(dd7MN3PuKo#s;QF eWy3`MDFsYR)I$*BrCnMrOwj?M~(dd7MN3PuKo#s;QF eWy3`MDFsYR)I$*BrCnMrOwj?M~(dd7MN3PuKo#s;QF eW$5O0Xecl+FchVxr52^;C8sLnWG1=!I65mB>KW@9C>R+S8XK4z fnE{nBG%_e`zPO!Hq4~@8_Al2Nw|}|Ly9I)~TX2HA2iHYH0*eQC_dtR#7TnzzcXvW?*Whl!?QY)RH@E8k z2Y0G=XXcsit(Mbgx<7r^{KID{7#J98Y#3}97}$0gf>^XBz&Q*6_I%*nt|-g84KHvS z-aH!cUI`8cpaD(!|2d#}{_psA#w+}HMfu+o28Vxy<3A!~`HzVIM^s7w5w-t_iOoOq z=|AH3dsj|BWjGFAVP*#8Jb`X9;uj|?dPBh~+r#XtW@*MH>X>K~c-AAtpI z{UaOyk++@y$oYSS<={U8`#%5!D^B!}{5$KE{JWz3FPBa0Kf>`J@jv=U#Q!4+jsJ++ zf28>TP4nqL(zf_7jl+LrV&WeO_>XK4|0A*gk$b*>B>R5^fbr=css4{p?foNN{}Dd! ze`Ml6q8Rs&Z2U()9{(ff{}E?>Y?#;oj{l**--s}t$j}oU^n+q$``N}@ zbcTM!p%;XI?_gj&*1xH#$YG!oqe5>m6y&9$=J>xf$N(tm=0h8Ro=_a*fzXUFe|rHI zMwPB_4hCx43epl<9_h#JVcv$b^|!Atyk}S5Su3=k49p%P&9%@GN#&^mx9PVF-wrAF zHt@8Uj)Kt;aV61E^06fc5xrCiRsXIt!D_<8G(Nv+{q40MiJNd^D7cq z&lIzDs6+lHx`+aY67yO|*e9?;vm5y2fpTP{mVT4`|37QJ905^TI0zdo%?d;`!%3C0 z1Po7~i`3$dP$j&h#_33q`%MboNZY}cL_qL2(~*$x0pqi3X%xJeTeFPrp3mafvlJJD z!_OBOq*^aC0(l@^)y?Jd0f`EPhR&!X zBLVoH^bz@_U(wJLOqkx;z$uVPC9sxt6VNKv1Sra&e4}hCcTyP8qXIDz>;#2$7Uhd_ zg(1PyjTV*f4oMbv$`T|pfwYtv`+;{(D0*;qO5}fVMuMVqnbc5!g0vRo6-)FOhrzi~ zJ=AloV2k>7?V~NLofPWe-Bscl78WK{d8WjT(w=sNHl3!vOA1z2|RBv#xa>=o~AvSELrTe17#826X`iOS3vV?P^u z%noH0CN+r9h;=mUwijMlvJY5J&x16vt3R}h;q~aZknvWQwTYnZH+j5ulm|raKdX~P z43p@{VG8mQb;EkJAXqsdem%a~p-eCf(`TN5BP#1HSR~OU%i%m9^2ch;7%Z{n)WaHQ z93+Yc&cH%x%ijGi1O!OaLg&-vhKlCQ-iA;GN~dGr zYL7dSN{dKq^6b>hVaw0`X9R_C2oBAxrjN!Qjyi=|mzQy>bygzgY^&Xgg}3z^LHU;&8xZ3DN& zvDe@ z8(@GCI+QB#8`NSgFUln>u6)4{M9FOAUlc=#RUYTfR{|0nezYc@J>KlRWdw?vK|RE5 z-7K#w2X?1ak_^!_wNRXpMU-1m>V1H~ArHMU!MME=B09?3i!d@8kr-zCO@q_~d^&{3 zDMAiwJ3F@4PL>%hg0T%AeKu9y-_ zE{a|%wtQ8f)s=LB)bu6hnfp6E6SS_pas*DZ^>jFwVy<6|WT}zA$?))}Nlw4;H>PpH z*=$j1a^>y?@y*#NG%1&(X=R-QxmG1^?2#tmRbJSumIye16#s%yE0bVp3r%P;dgCbj zdq2F3mhQ|zTOiDeKAS*OF&$YR5{UXz&AID}Zf_f5KlnJ~RAb%U2)5CkPKi%oEyg9W z`1~FQ*hd|^qdGbNrKs^Bgq%`Is>fpQ8RjkbUc)&#WrS_{ZH=$cOQ;@15U!9B^%Kz@uu1&!*Ja0(X0asca(}bVuMw7F*W1b)7QV;oLa7s$y?gI-DJ}fJ6bFJ-NX^ z)&P8@X$%l7Z{gBW=ug>kv)`*pmbXR+WZt1P@f@^tI2QKIxzw|EhV7EJRFFTvP9E0+ zsaQzteha~}(9f9?_|UJR=~Le5z`B3ItNU@yMhw+}}tc4&M2oNKN{zS(kOwKiY3{-zG#iDx3#m zn%`0jHd9Z!NU``pK(qmXqq!Vj_jjo_$U5gQXzd((oGX+Hzz>L!L0-_H^5$9L!EFhE zSX}JllB+E_XxzRV-CNOGZxUoteby^umbSI3$j_xf3}Adai}2(=In#J^-E+eZ!6lXUXVnYN$D?Y4zsbPVB2< z-Y#*Axb{|ly{L3e;gFX4+O$0Oc#YCXLttg<{o=E44D(h46bZHdl>8oCB3fnLsk$%7i`^js~BI>JtQzn|_E}*g~Q1OtggjN&cvjnvVc~Lsf`B80gU#QduxF<(_ndk{A|Dre8O1u!A zMeb!{=|+K>+2>{O=1-usV$uj|C9P@6QB$Aua0H!rzga?tg!4_U z)b6Bc*LSu}07UOTfM(I^`y&j?9O(S0rUJ`Rh!L0+yvhTVmI+0pS3=zJau@R-OgYc6 zpb3EvA?!~G8vqa|t%#V|<#w1QG5%fvtBiVx5R`kDK#i`VVOa8us4;V<()F#_JF@%x z7YV5;ix2l{UzZK=$RERPg0Vjide1r8Ias9rvVYxLFi_`_NV2HE7Ot@4UhAJ>7Kl$> zn@vnFVJkIyeE3-5wE7O`Puj{6R^L~byiB1*QRlFT^O zDa`;ZSAI2vpm`IDCz7GkHUgY!qrFr}s4O{0!R0$b;Dq)J*Jw7JWly5VWCA9PA*s5B z(#q<<9V|NrlYhnrZ=pBwefU)`8O=T}-RRVk1w(}16JqOCCSJSiaP&ZATCCYmOTrBRWi0$mN$g(}fK^^Vt216sg zA)DhKF;z7m5f9hr3Ao`*3Kg+DHwI?w$Dbz64z>BQ^&%BtPlHrLm-gxrs;bJhUP4K} zvX<4i(0-fA6aH4%a9JfNEA+E%J??xdmtAC!7uC;*Yk?8|a50u@_BoCJG1v0@9n(^V zloCl3*h2i2^lI+dW)@l)sU!iEy;$E^yekZbMOK<0LQzK@rWX!Z? z6<^^6uaVlgqp>YGYZG<$z~0C(qmQL)D7m2KS+~_A7VX&&>NDtYO&#J%#_iLpH^Ik_ zsw5QfXDtp23l7Vyd%kdSUs*MgJ<^yJ*SIy^;O1FH!PRehUxS5XmiU~Dwa%OKk%PZ$=;bm2p zS8vbs3|*0TKBUZK{$F8+avM<9<@N0VJDIa9(PESY&I>*Q@)VrUbak5>5hyd*=GKzJ{J^MDiC4($k`o8}>; zk!|g|YyXDZ@`1E6F#@Dsx@OA$IF`WLI8vF0f}%%Nr0RHK0(^qW@cgqK8yvNeQhoYi zKKXqk`=Ludjk2UT|J13xTVuX=qwk-+H+MNE+VmiGM5`jf(w6X8F=IbWUSF?Z@Qznu z%Qz@@+aZL;PG*oom=7Mh;(<*eVwFsq)7vxp@T;@=c5p)4m7V@?RYQkz*%nKMd_87$ z2w+*d!ZU~cpN}oY-e+bD!N2P&U?KuEbyWZ=OA;}HMN^eP8=^U<@ali-%mcLB?psP{ zOd45dN&F7c6AbIKz-pTuyIsazbuAe@nob)#CP15C>$_fD_$Y$)G4dAaqUfVsGUb!H zljqb2!?G|vkxM3hyFjC5i<0#BKbbVhjS2cw-3}KADU>TE5zdsVLk#8g6~Cv>1Edt= zq$$ygWeBSWS_4=yQho;DmrFM?%~`1+!LVbIA3mPQj&|Vv(*hQC>oBFE9WhyXQX9)m zBv2o%LZDFPU%d=Iv?O*t+LFK{;kdbKs3sZ5F%j=Y4(LNz1c43K!9*SIhY7^5LVnw6_<`oqf53Ma+ zD?iEXTwy03d*H)U+XXQz9kS4^7sd+bm3qA?jT2Mpkb4$cI<0!>%za>b(ZKPWMc+r^ zfm~=6WnPio1u)?tF8*FC4EZ(E9C^WpSUC+o^gUa60ctz}275w%*pg+M6!JjL(QML5 zx08KXSQx-~2_I^mb6CRw*6db73niDUmPmi)5uv+H?mSCe(*mE}6HO2k)jfA?C@7Id z5rvddtvN!*Q=u4u!`7&$$qUTC?3@J!3Vl&$*LIf6!@4icJz#Enqx~5Ib19CywwkC! z#}=+7dd+?1{--`!Tcb3KTbN}f7a>k6XZufed7O+EGf^bJ3_+7+Kk8@z>*bDpk(-am zBo>IXsE43odQAE_a$Wd0S0CSU{6~YrbxzUuIb%P;H{;Y#IVMSN7EPa_wW+i+OIB}I zWufYMlPVA^I^*VLV9GO*j=W^*N0^u70?OF1Wqn{Er-O%IK|2g3v3Xic!VY0rv9j0Zx|0JQauNset}TEp%nS z5c)N#qAY0!ngWrSpKT9YsxO!8$uCbC5%2u2SLTq$F@1Ji)_qMyZb2=lDl&6uRB9v? zv=kdLnw!@L$djuF-H!wA!X7szVeC&Aa(h{?{oBSy)32D@Yo3^7-fuGmg6J{xlxQpu z-=q@CX3Zc9_-e(M+#msFdsWrLm zkTK3HP+2fl58cLQ(fOqHHY;3m@?7-v$=wz;Vxp-t?{QJw0aK_%t0^P+$ag`1%~VtDvt@kUfnI%{A!x+tgJhqSaOVsc~a5rdc<(+d~A3lvL^4f6syPavE@D)CfFctD^ z_g~JVK=;5DUiCdJ$hh!-VS45h%N$ai#x)-)505zCfn|B0si8;<1~ety=Z>2~V!j&C z4tZWjF;Cy`4h1Faes2m>gZ;)Z>GJqer2ji3+H#@iPF!$&edvLb7&0Jy^uFrerIF#L zhl+=j&Wr^#JS?M_M8dC_bhM~_RKAm1@&nGE@+SEVop4&jUACQXZJG9NtRy@7n#Xxl zC#Tlx{mj6hhaOm{g^tWck-5$TRfiejB^vU7F?+DJ6;X5VA@UXk5v*D4ww>#4 z*moD*xFCyi6nb9|+X=0AUOY{Aravv8IaP@Tt8~N^;#$%Ne$Ghis}hQN11VSkp?g^) z880c&Q!09`>$7473xRpCe9a9_wrbU0yuY> z#)kA`5nn4UTfjSl*hAglC@q?Fji|W2Q#yL+D*9FAFTEuOkwxq`gOm$qsiN_ z4o~Z4DH%!$6y4W zh-{e7sYL!5ns84#nM> zWt#JKl5))U$Kg*D3z9v~b5ZQ`@dR{eJKVilkR#`J6m>$6K^8>}E2F-&J*Sc0mJ_Ax z(vdmukvQ*x{k+lI9n<@+ z!Hx_A(Uq5zEdvTszKOo;A6s$t2NS7U4z^RR>^s4+@NI~4ZE#wF^7YI=IDSi^qz*lG zrc%y#p;DnW$)v*2k-nD}4Gus-aDYwf#-sGU+OC2DR%@llB{E48OgZHEgoI*CPVRR$E!g5_}Y*iq5nklkXNRJM1Jf-Qu}nlx zEH%|C*aA^e5!YO>+!5L+wln+m@Ka^hZz_lSTJ06{BPioi`neo+wO&{Pzo!zm!F-r= z{~(-%Xfk_L;-NtaX|PswBMGgf&w2Z&`aGYcy^|tC=zAf1P6<>=Edgv&wv{0mRsyQ( z%06$`NiQAz{&hakM*S=M*o>!j^A8?&cwEa9bQ$=NfVTk%52VD@YrD|;#ov5#h^b|E zmFW^QXcv*>EPgWZ*V{m~(1h@Z~cM^`4f!Xmo3{iUe@$D1dVD^14mSd0mxjv6qYZPO<8v)%{Eq!wTxK zD~u}v58W#h*s6~^9O}C>4@^t?iPWPeC_@nbnsqAlA6rHXuD_o#taMEWFfb#`c%%j0 z{DX;5bDm|cmoy+gDu3s6iFrq*Z|qF0zl3Nu%MeLy8>c?Q-u~f_zJY}f5VDM>?)40-Ko!`VcgsD46kyTtlKPfF}yLNd<$Y469*EIx__nn&l2zCCyP zz!x^1YUZ?yZJd`|T!{R3UEIRo^AL+|_>`Ux9lJaEt2^<68(N5Wz?KJm-SxA@U>6qi zMnR&}S3m6^`pdHn_H)psv?*F#C~ZTM(A}=YyS$4kvQw)q$ps78TM1-r3X)wZyrcOG z-`%kZ8@aUS7UMk$n&F|I}fKwl3r0J2Fbn34zAFGs52YwTto-0!S6frG~Ac?(O`kBH|xM3-0B1(ZwuTO(G4pK=6(; zbfdwwyiG&NX{z#lAFZFdDjZWT>n>_DH{zq_XCI zf&H1;TklkOq+w(eDi60cSHi>>Q2Lnb7aMr7k9Rs4E!NI-k=x=J)(Q^HnvN-p=F1V zQdFtBV0RD-V8!;2AhD*ev%4%dA%E&8#Y&^f-Sq14bve)JgS4qo4gvrzssb!q~(I{eW3oAY+46)Rz&{y z8=G>bTR6m@^1n%+u;ewxjkv2`H!O`au;CYTEBQn%e{Yf>0Y_Vjx&0ja(?TnYCQ*K^ z*hD)ovzk6DA8?G({owO>$$vi5voIQ{<=dHxd5GMCy_0~8&?Wj5{kc(QVz=nyIq{4t z#?BZI+EK&Cc{flUFIc7Pg})!>o>zhdeH2Z1`EXgS!hP? z+OY|f#L7D6&2KrncnD3ENy6lT)CKD#{?h^~5jW)NVf28>ZdF@C#M9&=)3CJ#O!)9k zU1TKi>zY&%>iEDky$VnbG7etYd_{#1@{oy1bsk9J0hX@oP5TC7Q`g`RuER%ky_!M* zM&vqW2!-&{)rqD7fBJsQ9;i`-*wT#JnDo}~^Ng~S52a;4`$xeUm(Lr*sR$*y*d<64 zbc#v8pc?)>x2x991itp;!1;k$L4xima-6yc0lrvA*Xro;7lB3py&q?tU=_J3GnFq> zCP=*cR99igwQEBk3Ll=RO9B$p#ldYzAJ!pz_x|G$+AS3&^(j;X@B@$D>@2OQks4xh zRoA+-PV^|tgElXx^RJ^M4C2I@m3xyyj+wX_p4QJs@UQCPU=6#ufl{RRxp$TH}s%Dkh+D>UK zrA@;*Ppq;1c;2ThhD|34qoR<4C`U~Qs{jx^5=qOmFZ0`qub+6&`{oZS_*R2sbk}Nv z(1LSA3!Xx4f&4#=oSvnIBb62{~cfJeTI*CYU1V6RsPyf)zC_>| z%ppQl7IQkNI+8<-OktO5xf*3!Po2y*C$-?LkIiUOWzaB14{d|}wJgg6}_Nh;V-68N~ zif1HfO*-pI@ZQ9zv{rQ0Oz|r*QB@LE;cjqTtr#suxq5(_{3rp<9vnsxN!_+)Ny zZS(x#ldZZB@$`t?siN(whnEGZ(f3?cPy)+v?NU!=P_l!zzc!V3(2lZXL4LwfpiNmr z;sY8q;)a0ZWUS0^RuJi3YXbemPNPc4ZJuUp$HnI&yK;9UuBq5_J?@MIRu-jvTn?&; zZzw@f>40T@{^E3lyM!OAcnOokDdyy7di^4(rz5~gK4$-Z z$G=HEn$TOua#Os2iCXPF4iE3nh5W*Ct@_N*TWGY+8dF%RShI~z?wN1MNntZwq*D=b zn~G5Jst~+UIw)gMoqAVIvv_Z`TcSJJC!^IOjmiRYBx5SkVs(adx1(hsEW_I$k=mPH zp5Qfux)RO`?QF)9Bi8qBeqa5U?{6?!rl`5f`9pmzv3!E8vb=+@{>2(FFfch^cgx&7VZ{_Ci4o)Vtk zjHqO%%un`HFuZ(vFEqh12Nw)sB5N32aYvyN+3MMQAi|0@@iU|G8Brw|sQ|RQ}87y$X9WO*bO>X+?Xiw^Y*ipk^x{n)U&Yu07 zh=)I7RTt+<=8d|x>O$#&^=nSn2W6Y{9VbQ6+n4llmtZ=a-)G_aXji6lX6T(1n_9{_h#hFlX}NUp;ibfY60?Hi#spSL6_r|yxfr}_{Lx~5430~ldM z=?z6cDT-``9G$LR6%7sgixl+~yyp+|O-k`WU`SxaH_!&fRe{a5J?#?Z-YDq2X_I;% zh6XB2#2#UB_H3*FA@sHIl?u}!zFQHDkFWh)+{nIlui~@E=klABIXX{(jZEUFJmqW- zZ2|4b;jT-LM&h%gu`+ltd*{Vw*!6Hy$F=IytR5S!gDFT~aOWx~3 z#MACq)%yBJCY-u$(^PALaIL4uk(|mZ@)O@+F%SKdi=bhR1o5VkY>k=2$)6>G&|Ny_ z>Z4ehqo(NP!_8+&?2thj86k@wx}9IY=Ysu7lto2v_?4(lOj}Dw5!BTWOxfiGP4=jv zvwrWeM}NnA3L+jf>b-okY!s#{bu}0YZuu6-2uq@kgw-D0P+@&(=|!_OG$;%_m;v5l z0pX0!8?X%+C+u!a8ehEgdtDe$((2?SHoVOFP+tyG4Ruv764>RB_MnjevP`%tSaeSeZ@cLcTlD~7Pe^CJq!7ygp;2tGp0n=Iwef@oKwY4r%cO^t&k5sM~pJ6=i5|HG> zsL95{iyA06XR{+LP9Rbn8r%PV4bRJWep6DzRc|}J1srq>C!GQ=FlUt$6vN(;(fT31 zs5;>yB)A|f{Au(LKfPo=T2{sp4BNe0q5jA3kLc<#Ogp^vB2gu54P+}n!{m-yWp;VTO-|IqGMV!fd-Hm4>?xs0Q>5*qpBhB;+jKIDwc4A2pMBl?kI zeU5-+sum}H!ggCYDZlfZZ6l{ak{`6d48TQ7`9T4wZ?D#P_rvr<3C_v`t%-bmC07|2S z#)p6VPAdaNVcgbomIL>MZ>?hQDamC!C4z!fHfvjrGs0%t=)eOhd{_ajAFDR$R;a+H zHLLShPlp$X8VWd2q|>EBtfo%9&4gvKPxslPD$D|h9wOFb17TRcE|`-Y!9kvGf^AhZ zys?G=k}?Xz$s9W0P2%p}eb3Ki%LM^z=#y&zAG#$y42rY8^5ZS-Pi?gWNk1&dD;`Aoz)M^E9&nF;-6pUx;g%mnWId=HS6%bM(k&Pb zmiGxszV5qk9i`P&84Xio@vb5;s0UH8@vwm6MsMM}M$&0vbzWTWY`aFj#<7!~0h-hx zYOCP{)JpBuL>p6J7dRlg5Aw!kFPu~yntjrCdGSVRi=v&1tz3Z`r2GqwMNoL7FMk&!f6qnK|$m4K@Dp|HZ!r81zSrTg> z^BO8gsI=7HWM8M;8e(}}#&KXVbmRNTlfNqz+Saml*uZj9f>?wB+}M7C+j$g;XUlo} z+cQ#P|M(fxK|Q9%BWM&}psXODJlJp%U*nt_4Jho5-tMN_vQB)}Z6uS0dH|MSA!*>C zGN1+k#B}g?(Y{6gZ+uwsw$G#zvNl=*%wXeKKt4c4EpoN#m2(Kd;gV2`f36PUmnF*!geWq06erxvz zfYJ8XPzIoeGDk<#9IW@V?HnA$3@Tbx#zaGoGQk+W$2*k4G%@uwjJ#{m*a8#zS`< zK7xPBuo_=UY69?^e=tbZYP~@Lg3^`bh9TwUBk?m2Mx`-h!?`WwNy!Dr#uLp|a$)+W z8MYUdbQIQFymaA_;`!@kB9qOci~cCe!4PDA*I8@PL{MzV_X6#@vo`BtNgo1*kWYNC z*pGd1MTo~mK?$r{jli!>^YkGY=U!4Co@3rrxSUSh+QE zG^l?jm6gJR%LkwLkGE)A%PqX;A>>lI(?fsFSZDE0bX9(c^3FAVzPmZ9H-_Ii14~)V znb@c!z^T@Hx!>vNUW0DhAAjMH4o#(S96`?bp})JsHNElUxe! zK^*r36WFqe_HC)C(n77lP>Gu#jfohQru_WKWG#`MR69og<)gtjKR-3nsg5&R3t^V^ z>#zaW5@h*K77LZ`16Xf<;BA;}wxXjft%n^plyWZ4Qn~nexwKa7F}eV$CxFW4M)V=wo+!pX4EhJ@d)lmh)v-h?%{Xu z#FL6{+9vwKP=Mztl!>hQha+Gtu2SNeSy0KA;lSjEd(0o<5fKDE^*&a*Q7-P1@PeDU z49Nl%t-GBWB`k;mn{bF&rYseVesT}guqXazUV=_>egi0#r~%YPL_dcy;Tjhc9G2s| z18e4EvYE8lpKAoa?8MXYM+^rUcx5YS2|Ip$^vd<$Dp7Gd34O@PQPQrJ^;>n!WhCT!e)+@X^9u2d$@{>l0Em=Z3J-uSL}V z?U^KLYhaHWympqm?dSHnIJa7u{R@5W=Upv(L}iA_J`L3(nUk|S>VH~5?W{OKy_+NX z#${)X7!2@F%DLdU(y(MbDb)F-62*v|0wDXiDw>c%KEdj%4JDZ5f=rbNbCyh2Y{W$D z?>WO3T=wf?b2b!?fxQ4~-MBgspA)&7CiSNxi;R*a$^1$^U(|@${sH)7!+n)C`}fl} zq!+2CFG#XI?0?P*GFAWYr>twG-pJsMi$HgsMCE2yf1pZ=!|QRPepVzX-N`N+Hc`Gy zTlmy#$U(5G2OIB)sa6EdlV8`- zK4A<`5DzSOAwji99e&O8AW}z)@mdXcc4&Ay>u@^lnb>`kk5Of~p|M8%@DKC=bjL={ z&B-D_R8STBfz{X@21>H_d9Q&jDBV@kTWZ7#yV|9Ekn!%MZ654EJgwpj@%3Fnpq@AS z6{fY>X=0kuV9*J7-ubO)UB+V1;WKue%WAwl9u62uUwd9dwtpvGztLm}pog|2$w2+(cAHk|?ntmRcl6+DYDaB!iFG;l% z(GDl@96na50031f8cKt{~Q-*VGfs_*16z;@ng&xyd^!rk8XIEvC$wNTQ7S)Q}E1j$C|?Ngkstg z7K2lbgEYN7c4Hfk8Fcy$W0hnbF-t!$<6N72-fA<5exTEhbaebK4Oi5+#|FMIzt{DP zX93U`fBQb(0=w}mw}KxxSXCKXxg&)N+bb_bxxm=9N(b8PY0v}tI%Ovw)XKG< z5}h``TWb8Azqh8B#k4p-F(5jC|j(wl8v?O-_+*sgcg*Uyk z;Z-=XLzIxwyN6#tj+ZaUFVL@XI#=rU1{!?pG(^2gOz@q081h5zl9auRZm&Ox|GJ09 z+o3Rki6DsNMR45T9a0;LvSJPSL0F}X4A7xuwI)TIwE(y}TF@N9>cMb*8O|)!vQMkN z?zmuh*_#@T!C!mwys1Ak@<0(qSn1L|IS_@x7S0mlB9CP|MQHkQPZ=A!y)l-io50Hd zfk3IpcU~_!6Rzmp-w5)R#3fV*&;2RqY`NOkyNtU5EQXh-PY>S;Yp86@ZAKTeQ;w)F zjx(4TVhiX_?V_89&f1uf}y+ve`8_LZ^=2@mDk>E`Z@dWEnhbH28}`h(mOSGz+4nusX${4>Kc@bu_Mn$ z_8sSRmwFjrjJ!=D&v#Ew&dfe++&b8ZqR?EW8fk#SYFSP8Dd&DnyB3v|m>Z7--PklQ-t^f??})Wt+jmY54UG6&}9o zdEZKRe2G8N#UXTMXkw*C6vKc%Pl7CeY2JK@Nx?cY6=k8Qp>K0z|70fob9keI9vHyuJpe7XfbnNn_V6$lbR^e1t$(?; zrMYI94Rc!h&Nia(Jp__H2=Q-sfB#f)vGJt(T+wjW#?LJ4zE&D< z`wg&pkDlN@qXj)u*xhEuUg+S_hSaOaHu0>p7nq(%rO;W&5BbOUtF_S> z&kyY{ojaI&=NkRTq~M}N0Q^kTv})!X&i?rguFAJ4SlbcBLyTJn>t0JisxKl*X^cD$Eo+JkvZ+wzNuSoF7&;LefPR2@{uUSK8P*f&FB#TV@@>Ja_ zq8>ZlO^(v`g{jbh!wkuyRh$UDbTLP(!!mS8Aa#d>Rzv=!M~5#qQZsOw$#kc%jS=7i zeSW1Ruk0tiR~F|ml@0ycAFSNPJYmnWuJx700__~R@`G6bz+M4Vxy_Q|K5DAA^65gf zQy**B0$Ir;NO(+h5P3YU25#*ee(a7Wfc7A7g?$5cDX&D;ah-%3ZQQCQkVpbAZtE9s z$`5+(()E<*JWC`EuPc|k}DmO>FLz{IjOsN%s8@%!}rl+q>4#USLWG? zQ~7?6_qUG?sU-O9{ve0Ji@lvc%G?5WS0){Xx9H)UK4Nd%5w(r28tf%@@%T@X&}cvV z*MLm4R4~p-X8vV?z$x4+Ib@%^n>W(AeZ!eHolWxWdC&c4F7;X{GJWz?>$?W`ZVo)HGzvuD6>OL-Xk1eP+%e?lUx$#0QzJSsc@+P8nGQ{SX zV(x>xR8ON8h{_bbdg_f$W33TPa$dm6DX;h-yXYTJsQor3n41Bl(MmXf&nSel&xQ^vx}8)A1j^{Fiedpv-OGoGIa00$^5q5vu6?=^ zP+#wY1D`DciKtEWg+VBciQHiq$ZBESo^0E38U=gAe%;O-Ag_MkS6`PQN_Y}r7QX$L z)=?!eXqKqV>sP&7e#Pi`D?!#r1v*`VpgD6YTr*XP{1iD8f}$z_@5n0hDB=CgzQ&fO z?Vvb(X8{j1&@HW*9PBPPl)I|l6kBTW=#^%2s>5C#eP;Q=`&?!(*g!V(727v=$dk8Y zpbTy!^k)wv%M|`fS`ET*t|&*SQ>*DCEK9a*16<)PI3B?PX8r{xwP&kYtH}xyE|Adr zMBnmf=o{JGBs*n<2-(EoG7~VoQkmd)b1chpA0mI`Eu0SOr6D;@9_hR7N|~f`NgdZ6 zAm_Ur(*2iVF=$VI;%KaXdB|51JLKjNwc&b-h-naW^tAWfQK3r=f*;3s^Ol&`db&m1=*=&GzSiB0 z4S%z>JxOslWxLigcG5oMGs{OZsFcu4X9CuSlRL9XjCWEjE8=-@NVv%K)kWwg8nX8~ zKGG5WP_FDtujRwRpVo>JavSO<6^A2PxCB{yb4KWG?cb6z!O3F@sIViLD4^6{VKijyfEa% zJe0!Qe&K>0?$qpD7<99UCnvZVdPBR1Wx%vlHH>egvk#z=ayCZH@YWH{5_|+l&Z}8= zdiij5nP@#^GRY|Xg;H#eXNXi!!CT8~l6&>NEp)U$d-AahrY>#O>i*8k@0W)R*itIK z>V>bU$`71q#P!Fbl$&N?7TQReW5>&+r>UROW(Bfl*D^l7EYx6p%S_Uwq8xpz$qt=r zUk^UNGzpnq;<-uijxuL7MjRbU5*Js=rUoRx4Z=;}A|MfKD!?$nn|}tkJ%oD__Wr zp4^~e0Ef1lH0~THQAGYMNA?}pMxv>N{NWrnwu11~yckfS@ko|x1GPf+S|w*ufhRA@ zocGmhjo4NjURw61?Sri_?1UB1v*2S+a1@VoMw9}7cx>pCjKj4A!5oJEWr<&!_RoYv ze1fLN;MBWpfk0h4ad(tBxyf~P%%WKQ^Gz<=k=zgC;R)YEh(Tt_K0b7E9r;FYQL?6o zJ0b@<+}`Hr#M6P1-0SVh&}-@0v%JE|l1)2i%pNg{S(iBXgNBh2J`jw!~S= zH!C)dCXqpvOPIInTA$Nb6 zz6irPi_Rn+FXF8WVW>WF$J4#bkXuYvZChk^F)$4jtPBPh(qxTvy_ z_|QHwfbV73&T{qOOU6wP^g*zl8gb|M>3c%Ss>js^RX;1LmbG!S*fyc$2 zSs~K2Sb?h14S0X46*ScnmW$iqeQF@A+FOWq8p(MQcb3jYv#aMh6*QAe9dlL@y*DuzTtA{qKKT09?R~kF|+oR9ch;;)ZQc+)%=WETR|B}0_&sC8u=LwZ&sc7hm5`1=7>hMZ7rnXT={!(eWNlDY&&5w z;kFa9HZ&AM)}GPkNowf7pylb5RG2nheO>P{;6a`=HTG~qGsGt}v#RLWf4JV)`k@R@ z#X8M17~Nd}c`EXuN=)^I!mOA^5 z__zQ>+ySNi7nT$7k(|I=0amtgH5E$*GL#05RkJ)6NmL=ek{WcDC-%tPfW3Yw=!e+n zoNYrS!FBE@4sJzB`yNhEG98Q}SHric`Il?6w40ZiJw`$Qf$TGTIS5=LCTU2!t(u>D zp{{N3OL4a6KfiSZ$tsjuVcT@ArVd4g^Gv;9xF1sd?Pr^!nzn)+T+DD*m!mE-HvES;xi=c-RUOgt;Xls%2^O3k`nGZUp15niZRe;|Wd!J&qT1RuwTuFHsR53Au+SXjc{cSvH1zZTTFH-u5 zB-eH2u|LH+ylVMY*nN9qHc~_zM9mWj)L|=REf2N_#wl7>bKy=4 z6&f}Xm>I+O7$&MnyvIStB1GqrHjBzRm5w!XqLr)3$Lr2JA#qM#*ec4#8GjncWTnR= z=nBt{#0%K@L9revmso4i_?Mp>zPbGF^K@(V&oLuz_^MS#eoTJ%a2frA8$2}`mV2hy z>)i;tu4jC+-P9m$Gu123_27gda2eVh;r?G+0Pjz`HK|GKW0GHQ0REj54!0d(*lj+% z;co>wLlTWg>o6VyzUT;8{ms~R0DB2kwFn`@-$Ce9Ty42(_>sB35I2!S(-)67+!7{@ z;{B}P&a)AcBn){OO78;>7 zAvNC|8}f7r=9{&QUvNNAaScDg-5KU6Z2h*@=9Ss_gKR>lh#-SaFPI^39{_N!CG7H%m?YSL{X? z9KI!rFHRV7n;Po&+}H@j=APp^rGG(YI2MC#o5vo!Q2 z@I-R$Ik-31Nk>9-JciD@XOQP)ewgo5+QD{D%&5Nhle02^Qhss`OIRwn9$=@MUQhXg z6P`Zkd)ytW<2>6JS=*n{cWMaNJO$8F6Ap!rBm^j?1J$5(SWloADS2^Jrb@2~hBJCL z<-4EROcV-_joDmKC-Fp8!HReMne*DS(}^cfE#WyD2&o?BRJ{ z2%U&GBNL->4q!gl^`R(?s<4}0NRX%uPdN4QDI|8Tk+v_^xR%xLUnKuS{5+&<!5_wbo9dNf`b z#@`0C>Vc~GdY@w#ce&AiJV+UEyK!HKe;IJKkk5@D=G@zqenW0~|qDVUkYXYU5IjMkOhiHUzK$m$8l; z|DEI_9+!zs57-@w4LhS8`O$dhZRRWeQc&FG8$LpZT6c9=qkHmd+nbC0exwV>MrA}DbZ6>%vDpMD!2daoyD%Qw$3Kvi1Q|~X{*7BO3 zcN7zH&GmT-ZZAVl<-`a*Cgl24RL4@OpIgya+VOb0 zwkLEhv+C7MxUUQSpFm!JUvDw;)wUY&>Vr~=wUvH+BXi=+e9sf*{WI6??#778r_vCq zkt6wcrIKXrHHE^BX0g1O29uT9ANe{ScGtFge$4gj69YdkGx?7`C-&W9fjIJ|&mTNo zb;K7?8LBxF#MP$tc-OH9!AaGB65TpWAq0-3-g%xn_ZXMVhaYXGyQ@Orcbq@l)$pG~ zFAn---D`8lWjL*%)=~^1w)!C>S$V~UxMiZAmuhw9ghd@W7_AkvfVac#@lz%f-zfqi z7%8QZH0V4WT4Qct$D4cZ+4 zr|?$jw_C997C8U~GaXd{_Y$&47oa`PeIem>62goZ1zK{oUM128PpIbsH&d168!I>( z1L>7G|8`8+nCF-ATZ}f#_wgGk=XOL;f1T!daqS6K#hoU()ur{+*NS+td$3;jcg1_p zJV+CWjZ3LEVbJt+YWTOYlv*dbAA8_Ak=C#nU;VegW{$|FIW-Ehe~HglTVMfiUXfJD zP>T)NL;{Y+=W0C$`Hh@E#@H8N)n82%WH4zeL;kLIuOb!RwwYs8ASb8MXjQ;ASTa^t z!fCXdLUE7PYRqY*h_E{7aoJ*cBSbi=(uDg7Zos27NMP0sHS3tk@=O2NOG^b-zq|U; zFW@R9vVxk~FJ3o>4?lJ&<;ecUu6BJRa&0_;PFK)-Hua3%MgJQ{*UHc?QAT1&39M*ACEUW5brCG35Jg$5^Utxzs`0B_l*Vp`gfZ{~f}3gcuU^wt z>XT)=l3=!b)KZ!dPaebhP3m2R|FRfZ`8lqG6vH6t`0URICDFJUdDy5(VL>J_d~HgO zW)`eLg>!XF18wE0d?B4e!7H}cKsFL~(r1;2@{H?1sai5GLg}fm#N&yEYBZJPX#yBJ zS&kn^3$@Emge1)>LdP=pVM|y_IZnlqeRhrHe0&FEg!F1+ByHcLi z+U4c7#43o-ong?VjhE6p*+HvrOEB7F&|Shhcv|9^HMh_VF=}`ctQT>)%q6cw#b4^d ztYse0qCZX$>}x*ECRH3*vCdx3Z^ zmacL-)Tc`dM;owsvE7Q`8jk1FEZRaqsap*mcErTO&(2ox2u_z| zzX3VCd|L?#Ls%*pxqJ;#_4G0aWA@AsE7!faB~f+nPMXjXNFuN_YVG-v-N8vgU#_ax zBt}|4jrG}aQ1VXbm8l8$5EURCM0J>yv;Ydy}c3{OgsLHwLCR7MXy0z zCs=v>r@gjqxy#H|<^J?Y>$?$x;ZbZy%38FpFS$5tu?h(YaVNaqZZ(Ok=CabMi5>4? zp!RTz&Hei$#fNJ7jT`$)HMJ^fp;U7TeV#3TAs#5zDY$|x}R_TvZ zu@oUgP8Rtx`^%8v_fp@B8yikxVF$MMe*rF%tm)7u>ZQ)~{Z#${9ZjmslXSC;awT^| zILQ(F&(GQ?i=xP~{uSlr8u2us*%&Ci*%5uc&7-sSOrj_a?!anIO)z5V$^HI3j0CkS zsm@+zKFcjXiT>Wbv`B#W6#+_Ga>9RZTF!>yNnCd*L&_E9rcX1>a;*9>aL+!X66A?I zqB8dOx+ZJL%l&P&V#Y;CO^uUuO@#x)V7TQ}1(;2wf8>3?t7#KSSFEy;GL}-Dq^mqC zLlv-@p>J0qjCT41Ab%*YM_2p)Oo#cJM6h-#Bq_00T#YmmcZaWoRo`HW9I+ltJ*Y!g}s(ft{(@GjWr@s>*1qfX|HE2XP2 zz(;E#_mdcE|L=1q)tX@cCl_E1Ig0m1H$wq`x%QBE1mXGk{hY8c;zMcPKSf-0nJ9`* zMhNrXd5FvvV9Wh?;5civMuyx!YrtqDM8xKut==;x>%4&~EDQ7!8XGlX+nL@hLcmzo~Lepjp#qa4%6@8!N!+mTCboOOM&{J!Jut#oo zMmq0Z^MH8?E&#{kzh^MW0Dc7vnZ6Ka8|NUx^U~G*ru=&q)JS2D)WUmRuB_U!t9k>O zlKW{y09UZVD-DI94fJ_X=#x_Go*QZ#HzEQ$qQ-TYY5q2Mbc_7eQ<{~ zU_~<6kvX3|GZ=JYy(S8P0{tJ7eQ;u;h{yLGd*-0SRr{V;}%wj5-w>0W}siinY7LZND%%Xx&2$Wan3WhbPO2Cq#dqA22vS_^**Y&ZHR`EP)1!Pc9A{@^~zKks3Z_KHv2v)ts! z7rj;G(dm2)R~pEqfUV{iQ9blt3hr;UuCx=&7S0d3w>p0Y5W-#XHn*|q>Ww&dcI2P5 zZRMX`!CdVKTsQa`h8m>oNT_tmLs21^m z0;CvF4MQU%PliIOmTA3S>$1m-_`>PZW9;Ai>{l)~xDr7Qv>^lN2#gsGO=U0?u>nPugyc@pk+_ z#~G0$q&jb9t7fv2sf9ssZF$-SR3;CQDOg7TTg|8^h^wF%W)jJ^&($rF^%M(~y6dQv zsBdU!7oAH)sR5fx4a}_K|Nl2Iv)QJB zF5tveIuTu*m{2W%(yKnT=4MLoO=GQvGfWTNxdhXjWgPe->jj$c4FhQ+1wsNN`_6k# zj|424f{>XwY&Y%^g*#4wpJN1%)axn z=smZ{ee<`e>7Lq%>TbQ}4>;4d$ZPJst9|h4J@nap=-hT{o_NgF=6hcy>@cr9uT0gy zhz#_DFvh#}Gt+qtaPAYeS+l#Ia83ksA7zO6q3!(RVd9)Pl)x?hS3@od{0a=t)UVh5UW$(xHX`NxcQU6kn?$ZI>2-*Qx2yGSSS2Cnr zZ3Tnyj39Oe?)~I>ML~Um??jfHJ${|;=L-CHYVD~Gv3T6h&ZRm7cvS?v_nS4)_FdOQ z^9sB*wY3Z*!ff*us%4W|oM9<&a`7fu3BjuV*HMO2_&KQG;YN}Wrs*gdX9$XM&wl?X z;`nOCcSB84B`+oLg-}3GpSue3NRnYF7N6F(3J&&0iNxN@sv>=Q;Pb4mLwCuI*pAq^ zDmw6XD)l8}W!_Y9j*;(A2U!jpy1!38eI7Y)-K-@W$PRK4b93mrmLM0OQ>;iMB1lP~ z{JfafS449)k%$o83&Z1aQ4NREDh?x_gY~3#DO5`-m#&0e!Pn@yz{A&A95^%f{`S&O zGy^*=W>0T4f2W)~hey(<6ppxa=^Iat(8Zu-%mlPw9+liX=|9X&tuMs^2N^JA} zzBMMPuw%}NMBLBM&rIwF0R8l8LIu4t+jKa#*-RsSa=$9A*{tm-l|F@Ti zJT|L2S;p?-kDA|eG}Ckh%}CR9Rz{B@nis*`A{AGW|MR<1BhKJDFT^GTm~}NeqW|wR z3Nh)RH6Mzb9h0`kF3%7C-}fWO?&3r=A=wz4KZ94t_Wa+AM@S8et!FUx7FhfWzWyH= z25?T&zwz!V9;Cs|%N%8t$mO@NfluGzNaS>&ssw-yr-<%I1*9m(E4PK9heV=#39jP7 z9lm(w2wW&WS>9$MoS9p4w6U|eaNn%-`1!=k>SmCuI6r9NQSlWE2+FMs$Qzj$U}8+( z)hceUC(8Me+pNyOosmsCd_@r-8pNcZoi62!pM9A z;)%3~lEb!p+d}YVWt_41wTSQy#>D1nh-EN)df+r2kUr?9X$~5#7TXm{f$Ip?X+G6u zo4#kkn}oP*mnm-5G6lbj_QQ0&Vx%S(jXDFr%kGC-K(q%#5p9;L6KtMrt*jC*4FlfX zmfwUgn_Ld3nwdSHkIRH1jC*=|@0;uN-B--~p8qQMEAXDPae=LOxZ$#zN5dG|j{ohj zN=D=H!-3Ol0F7R44|>sb6au0yFE2k|rnuQ`qX~+ee6~evv|VWsPo{>`Zne*$(hvkj zUY?^V@}Xd|^o+He{E7rb()D+}vER!*|HtcXiNfJj4z8wv4}WPXglrlEKD)JA907my zAjY2i?fGKW^4@5YIN237nhcI4ERFZskKw(TJ^AH2L&j86voYSWJm()i4;R6xT=L&r zztQZCdu;l7yuUsbhCM%f+lEI(eFlfpb6Zpwd>U1h%P`>p4&$|eEbceN7-I;P3X_m( zc6QRUMVvhfy`s;XzPFQ(>H~W0={}zNE3x>zB8LlKU#Ss^Y7ZFN_fSWu9|GQQ^RULg zYa1Ohze$MwO1=U#=G#eC9fv==ZWk9RwK_zc(1yuYUtpIqZSJo)jg?>z`}X}*rsbx; z?KfiWjiO;lYh0=<<6HgTn|VA>t-5<2ccShH)X!|{x+&<8_q@SsQQo`;NRj^>#-;wDHVL6Go4 ze4`?`6CO_cgUk!q6?c%%BgnwXdu@b2{kd)o_%>MXT= zo#w^z6m=qlm2#q4{V8-60e6S9=Ni$UN)PrNwV_(bOv9boXQm!*m6+-=O%WGyhSiS#RTF$R?tm~!gvu z$1wc#*L}RbbY{~XYx_S zs)~USD-j=+`GtT_YXN7QS>LfAQdmxiA5a^G`@v5=GC`78;nG-{n!BrD!)F##+1Lk& zyr@=UJO$-F5Ktx(hj{@4c;ZFgGaQ#+NA5wEQr7#bdC%w{GeUltQ`n8Tbate*2j&y$ z#Q3GxV2_H+7TfC^PXrNp^KmX=+&T&Mn{FwHdS!p~`amVi>Y99WGf5fZnb}sTRN87y zR`V3KC?lqp4E+`fI$A(l7p`KQQsastyV&SWQh+nRg(igW{c%^kxu%6!AWC=q8OAs@ z7{v5|2B;Z!5$E~|YtAwOagDXtr`z>U$W+e;lsM{1-&RqfiAW`)%g4_vOi!j=UlI~p zbfJYOHbx$?3JavjK#r3xUY8nmikSnOtT)DDv zf3p2*d8Vs<$q$g}{R5~$bbG24@x)N{IVu&YmHA&$SXsVedUhh+o?JZ8F90|%wBS_BEf#A@n6kAl0EbyXX@K0Ha{x?ZO&gk)z>}>%~gIY~l^n%jgF%jRa{24HyL4%!q~D z@ZiOq+yS;)sd}|DJzeCL%PAq1PTP49k&{lZ%@H_PJRox;2`G%hqfmqRAt{hsG zre3rtYni^J{9=QWAazxqdAt?L+FyXHdAZik8l8Dxh+H^}0`b?>O#vzTyc1+D44)>T zJ$AWJF<&a50S) zH|pba#h9KhE41*Z+CI&l|-U}$bhhtV=BnkYhE6hMq^$1r7)IIF!$D`Z5A(Np5;5b99>14?C`F`@prj0KPYwU{EJIv$B#F$-ctw2MT^-I>FirRK zKh4j&KPdv|VZPJ(Vy4KqMPm_O?VxLoZV!f`z?Yl#wKPE|E&Yms`UK^@8^gI%V6Px1 z`knW(E6HO2s?_3!T+Wa|M@MI(6O?7a443x7!Oq@&Ge&D{{5oC}zWhRO*puz!Mi3s- zTf|s(O*)+b&EAKIvLj@6ZRsH-+O_$z@vvvkO8Z4B!$R4JPqzpCQW{PYgcCmqHho$U zMgVskjhV3@i0FI3ninX;UFn>lWR@a)N3Ilk{HM8U^!}FUhwljlC8T+5=v1bD`oc!H zPk}ct9&Sa`V4gqC-DjtD>4Obpqo$7M&J8g*TDh15mD+Ve1)brPn6@UTLc} zH&X5hlUhNizNPCjQDXf4TaJ$^@9yqyZvQCfz|9lo(>v`i`;~^0@iHQOHP6&7< z%s%W*_XK1 zXV2TlD^0yhL)^XHlUGiExRDDRPsMaMMJ7(*xtv#!3e9|5PHFAcBUb|bzz_S$e6~>B z{08@xmpvK?E_Tf_1h($3Y)G1tN5&EOvTT=g`F+;t8b}ZujZDzx{&t+!?!rpG!aW;6 z1NKD0hn+0iV7zxL#}9}P_s-(?QHp8MVI72zGSE-@5XpLHq|2K2jwp54l!rj%NR(w z9za;OCuy1DdORl%??E}9GDtT7X_tuDGS}_nalvHl?#%DF)yFgjk0#5~ZKLmxV-Ww# zXbRsBR@fJ%078dy?gekjP{bn(}BpCm8Ru4Q<=o+ROu|mnCPb*=_f;nqyH0m}- zc$=>^&hN~$4XqI_ky7p^AnvebLHcW?eU(3pR8))ui^PquC;r8$eM_@>I}f8#IGP6X&K%m4;<}g8}>0 z8kNUor~u`0G*e)%p#au&J)Og=89>0@@_g1foqAPDWoRuE{tc0c3W157kB)vCEDvS0 z=d`YO17>>J>n6uJ2H6dP{iM*Z; zMI+I6ra>{&*w$4Kzt26}TcUr%>A0x5_yeI7X`IOT9&t2FhPat-x3`tpXs!vNup}c!yFH_n4z%6FohhR=4|e^F6C{n!f#9Q55O}b+zwfV z21mk8Z(54bppqX^>Z)mj%}!xkWE*Sr?}$*wrI<^sZ4{HjHB4=BaB!UP^BET(jgMK# z%0^v}8#xlb9Z5P-vd|gZcX2z0wl5wxZSr9VNW}ds2NNgdGPe_HJJS7yA5v`jz-A_L1b%h)M4<7ta?dh6V_z}gSykV^t+CcoN#tsTG)}0p zu$JKz8tdfjKgWJbV6`%DLRlVmq0!9E=q6EPC=?NQ>^KGk2zEC#du~#s{(DC>3OPq; zr@8cSCir2$*B*;JKaMoagES4}k|1m~)RC$bJjCnhxH|v4s%k(_nkNVX(YAAZQN{xX zG|G2Qm&&3a#CG)Jtv7NKHuf4Leh=$m1Biz3Sjl`G#R3V5AxdPFchS!7mm4uwlEjD| zWf7$rCib1@EnF{jlCl|q-zHQeydVN1e~H26JmDw*JGzE(w$`p1#lDj2oNhLjOwDJR zvcUqx>I5ya1fOS1oZxb636y#*CjQP!653Unf66tz?*sn=x@a^aSDRIW8QT`_} z{8cE9@hh$8Z^^fV8|HvfH_+2DYuoeF%{Zf|^MmHJ;D@i4=aLByyoUuh(6CH1B``j# zNKsx2@u%R&UA-Gzdw7(A!V`>Sdz?w~Al)2stJMgyl6ge1gs7v(pl=fx_z1Dy#1Y*K$v1^o?9U$@1o;S)CO{9onWer!BaBhTMbe- z`I?4)?2c=5M4O^#D~*)ENJjA+pKEK|x5brh&NEw$>sFi%uL@)gd>OMEgg9r zi#315LzRMQ3-%h?BptChZS7zXdrTA-BZF}0jI_G%ePbT|IayqjpVNvz)!J9AJ8j3ZH!T~^G1<3(s%lW;nboQ z6bVQ+dAxZYnovw@;XI;i)KYzy0C#lVHyv}#11x?Ai0pAy9kGG>g`uG+Vpl^2kFc5i zpozl7d=wK%@M;r4=DjV(KHFl^42>j^l2U@8pDKRT3e4Bx!riVg$6m`x-%f2l!H8ue`Yl+ru|Wk1+_{6=*45xZjaR2;R|Ifxnpe($zHW??&4_|7q|v!QAc zbbaFERgHR$5R<*xjLX6q4f9!_DXv`vRU|5PI5{shCB1Fq*c2e6iPtLP_IR~jA};!f z;XEOta)u1qWN8WLH}hjFV3fsh;$BBjkHA!_%)Ck{V7D4M{PDS2eSSiDg?)qxz8Y(t z@St=*jvK!3NSranW}2nOr?M;L3Gh=DV_bZR!{eUQ*#E=>x?SCT=b*y#MWmUc)4K5i z+?!1Gbt*kDi|+QOMTF;Mz`ZGuoAN7%I+)DDdKygbq@t4djRz-u?Q?+y`}6esZ|3Nw z8QarcT>$@7{C{5JKo@6^*?8&*Y=gBHS7be#W#iA>ckEb1^>4tgL6?(K;8hYC#4}PV zWNQVPd(xQ#{&;CK#Nw5-BkQ1v^7MPcLxxe$;{$|oV6tbcjLsmC9t;RYpL_!dGOL>LK z0jB3muUu$oe>U$A=?dYtnVo2$<^EEl2n#2fQ#nGnQ!2d+o1MgoESc?fkWtcI`aH7v zcH!}z3R=4$DRhv9rLE1-Zn|8p;Jx%OoHu1**6vh>MZ$~^kP6JJpW(im2zraxy32M< zI0bT0$@ef7H2hLrNX-*;<_;+QtIcMMVBi`Bwt@kC06~*O_azA!bCN)dWh#w9^!C2>XAcxWVjHo&PcK6q=|`9 zrk#|dH!ZYbQ%ARU?OrrW1-AaWm0bJ}d=`)tsJm0D;v6lEMz&9K&Z^F2O2z35YzBwj z9|!+i+>6aV>fVpHON+_O5^R=xx813?N;C6qVrfs3v2?Bck+AamCj+nj6cOmNHDUcQ zB>awpOq+a=KzN=0#ODJHTsMw~Xk1R9bJ*NFtEY1zfg#s<8cM6td@^77JD9~>PGHv~ zkjWd1xJ?`mofweqUOG2IKMN_i*y|>vc>7x>_%<>+OpxV1=ls5{mxQ zcZ&JXy?B2QndI;FCO&J9F?M_30oUv;L}Ro5obG)-sbcInDxAvUJ*9S%kQFwzRHDxd zNkV^q=ECgIe=n!t{8E`HI}R9^!)w zt3Y08Bk$M3@csF!`ton^jOpi{4rThz!zpaEL$4hMYe12`j+Ag0$(j}-;qySmrbte) zBHct!c-*|zc2uTYFt{}vy<<9ACT6sGnl2>y7d!#JC z-|@G`-5?FRmwM~I?(5o>fGotseIAkLwQK>SKvl?gEmc1yVG`2!L8Mi}LOxlkz42X3 z5jI+lYMAVt?@uN5E%U)&vTKZdQVJC&1L3Qg8jVi5yP6p!{)r~JB@ z+$Sd_7Q?8=l|2C?mZU68dNXAbo(AdNvkQuCXF7WYN?S2=8~ zO5^t1>tVt}aI&iXMk8lCo9)ptATNLZ`v>W`Il*C%GbrBz^Ab=8%?F=D8gsF!6{EjU zSD8tC-IFE95}@gmC(Uf#a%Ch8rVtcM}>Z(SK&M)`r8#WG((v=)lhrSKh zge|H>w|yQidU|@5OxWFw5WvbM5+yRpZb48yS4@~l@XLHdl?SSxr$=PArU<3x`o38fQZ(XjCkxc!H>Th58ryPcrV6nnm!nCy zoK#EY0w>bz)c>CUtpB_A2($)hp{q7g5eOyRQ{WlD1|*rlztGKM4NwO>4idJN?mq`Y6U^uf&TRCdWLhZmn1(^h$^yb$1J11XAS{5xaw>*O z7i}lIjM;e%(nryBg-3;nu)p%W;BtN9j)HSUU_>CHzxC*S$)UdLdASf}5KU7O?XK&6 z_ZE4GeQJ2NygzZ%pk3>5Zn1HuJTvq;Z&qQaDd1CkL%DF{ddFun`bJQQ$8C=c+vvTO z1pO);~0|sALL%YHsxdsdmkaeHBZl`!pO=dVS@;)u# zH5%as3t|MktZv$O-z_nl4j}+k(H}pQO~L>*%E`_S{8O)GJ(o$KdbHO%0@fj#u2*DT zEdxp)9v&8#D99bjk3cX^l&=EXNdi?Oef)NI-hMxCv$#>{4 z7aQH3KV^3K9?n*7gWws`IUNXiEypCocSSM-D^2mJA4mprstpJhBhfqMnJOaVE9ELn zOG?DkI|4r54|9ELoBDuKBpjraAe+h0a3E;4-RRIY2;hZ%|r4+bu~M<6jzzZ za_FH(C$&#t%KzHZ74o&^>JGT3HCQOm=c0Y-bqYZThfBx0k{#;U@D`%rAYGh&#K+MR z{wL>Axv0hGavRRj18!Sp{nU)_eYr5KFy=zk@w(g!HlrlQslT&qnLn6To?|piYL@*_ zlKv=h@8Y~(51`RZpsCJ%jzZxc@7X)g9PmBQGWY6oT>b19we;${&qQ(3pIFGGym-)B zdvy`6g4cG+vamIE;w;O`a1sW|K1@e^cV64>f`4V*&7>Jn>cA`*(Mahov}T};3)(xX zD=1(yN*j*xR&nQ|lPfkHsx!smrd5L6`VoK`t;#)0;shT@JL%^w)5Ie=d3!3Vb~O3> z(|ozC96$*>Sm*SDlbxy!`vHN4Z14upkoP9(d#oTACU{CyjMpGATh47ReZ@43i;D{f zuZzAMe7uc_)wjG4M!ISTa{Xiz8(pbq344|Gdq;jVCA9L@a`5y7efdB0Pw#|hw~0XT1F8mYDgGoat{_$` zViXkki3o5k{{8$z;2#425cr3{KLq|E@DG812>e6f9|Hdn_=mth1pXoL4}pIO{6pX$ z0{;;BhrmAs{vq%Wfqw}6L*O3*{}A|xz&`~3A@F|_0w0fK<)4^j{`=&Qk58XJ-ammg z6`QH$a$5LXGQh;d+m)zHmVPgb`B6w2QQ%OKspFm}kZOl(XQmRTbuLYaO4DpnIhIt? z5^5!L;v(HBDF#yzGQyH9AhohXW$YZR)4lLc&7;C9OsLA2>W9|_QhEZJ%sPRS=a{-K z|5nUY-*+q4u`OW1@0={dpW zN0%wKLE@P)NRDYkv&?jq$lT{s zkx<$&?1p69dP^!TiI|aDYFIjt=z3uIko%98;Ba(axEV#UQ4lGzbe2f)Fk!z6!dq3P zE(U$UXWa%A)QGXf4hj^M&f8?q?|E**My_)pTL;u(veEAP(+RW{2Hp_~hanmlTGM~;gaR%ldm0npJcJA< zxP57a^wrl|%dQ_3h+|)!7_lK>p$+JL%_v#v+L;b9iq2OOoiizCzbjs*|2Yj6;wx%E z@iRk-`!~^5aEqBDhl%JI9#)*TIEuk!F!@~#RC*%6>fXu zg@fXdD?H53@!xTo`A4a~Uj0UG>j?eYks#9Adk;y4=TUTe0?U|F32(I7L)Ur_cGFPV zr32Y!WstFzPq(F_@hCS3z=R@++~Z)dsuJT;>G)MF0bfogWxn@qz~Zle3@+o(5o2Qa zt_(_YK}V40txlM3Qp^5MGqtjv<*tBAKp=e&vt9VDTB%$@7gMK+A}c#H5<81`16uQ@ zRZsSJl89cmqh7MOUcLrH|r%IG_Yv(Y2NN(Hbq{q zD)S8#nrE|dM{DuK@L&6t?-igQDaspQ@BH==G^^8NNs$)h(qp?depu;-b$gaVTdvd} z)BJp9ktT4uro%eEoEs%@!5GRQLvF28Ffj<6vvB7opd+r4_x%`cM1dkdrDsqF*NA#p4{MV1uYlb z&`KH-3zgDfjoWZ>{0;hRm#Kns;WwQdh8CgPiPyYeQKONiO{6W^BWY>LGnRp^{Sq6# z&ByW)x$$G@F3N67n2Nb6Lq7)^R?`(pE}9;F*~(X;7DPXV>eK4#-N`BU;8;`JY>ks? zR-EJ&dsFDN*qy1_ZH0Qu{qU1LSUpP1Qu16JqNyfNS(4-Kc~ZrsleY!g+ROEaCyZ0j zh-02a3;hUKpq98O_QIGB@_&v<^TeqDWky(6FvNOIHk|qBxe2STza@E8Tew7lvi|53 zD!J^-xF0T^(pM{tf~`CWDGHl8%+=Om!WK-K&{t$uVZWW~Bpv=qn=FfDyLFgCEQ=2R zVdcI5j%Xe(l!99tlw`TY!NhW^%JxWpPw_`L8Y7R@v7{--FO*$lqZ6er^A|M%J~g#2 zE7lFN=_k7|RI&~N&x2IcjyFFC^WLc=?(-9WPETiz5ODERz2~2C*k)eJQ7ai_J)F|0 zd_&U=+f?;f`!%XlEP>QXk zegPLx)Mj|6htq5(!qKs$xb09ex>_m1f=`g--6}wOWk~BdU-fmZWm>mX@{kf5(t$ z6Yvjx$8#=Kzvy<*4^~(}uN0QIM4h;5NW}bDP)LmGVJH*doA>B~HQRx3(rGJGnCJO@ zZ43V{*JwvqqN9a+N=+`6FVsy8kUNhO{T7b36H9YNEh`FiFeYb8hQ$OG(3P5k+_L^e zz;x$)dQ@CoihS}ENs(#q3`w-Vx#__#$j{vPn)dvRtDu>Ovi=J<#=Orv0j!pPP`A*5 z)JGsP+fN!cYF%l1ZJ7y$>a67EnYD(vyi=yfKf|QNtT0mQVpXQTZ{-C`9Li@Ru}mo^ zpvWyP9~Yru>6PR?QSMDF$NZJe6)G>Ok{*iTUK? zw}UfzlJF$sU#u%cjk)WJXUJ{QBoY;kZh`A4dX>Z|j=Nop zo2O;c%Zt2i)ujp@4ABpnW^r^t!)?O~*nI=e*_euB0$U%CKCj5tY<=@3UD{pOw>fi64HNsAq@eVm$aM!|sCB zhK(^2)hSkqNk@JHArn)|+*HG%gba;kRk!;!JdDQc5A?sb>U1AFJ$rarhg1-aC$^nOH(+!QP6N zUYb!))-=`tHAqcL298VOGpelf!Kf%joFdR2#1yu_S?y?skc3I@ol=2kqzMI$RX?Rg zQ^&ohi?Are>_G)ZNVRE36B+dLXx7=H2h^xh=+M0Nv3yg4KXpUxoS0O~+;r0qZ-_L} zAHopHW6e**%n%~SrFwCO2}Uc05LP@n6>@^xu=g=c zcfp>?FPuA%yq{4^SKJ7(MP*8zQxYAX&&CtGp2ty|g`=y&#ZgCERvHfq@nB&d;#}O< z=32O0jL)2JpVg{W2SaiPf*|KPYC|dmXjLzwI$p?}J>+T{-DGPbj`;Iz(pdY=HYyL8 z2dH~J^`e$4@n-Y}(LuB&XDKM~d(_ztg{XWQv@BVwlZ;s^_DPAg6(zwOzh@);kPYRT zWNl~B2Hz6yHS)|oep>xWCRk#yoMg8m959(t^pt3a?Nt-*$*EqyKG2~5o@b;_`O7Q> z4t3+#6Rd=X4JsCotI4u2@-g$a-$2V3j2{_`c_nSV9kUSUJ~KgPh+fPQk*;DtSJ+o$ z+04#u7Y$7{R*OfTV@!^dor>hprxBI8iSA5#mwd{KZLDM`TU$r!U`DH-f>sZ+qxFe0 z97FH)Y0?9O1$c{Cb>GF4cfP+7{gSt(dl(f#C(q8w;m$0s&O@>XP)=dpE#EN|v8&mN5UXyu|hd>mlRp@=8Y*-o@H zS(Q}t1uy+l+aW4?^_@!R#j?r{bC;Z-x)C?4TVzqO`r^14^J*<-yTi~_Z-<|im2|;I zt?~ClC*APU;v96pG;^v2N<`(=O$yKkB6u!&=kgxP%JspJ5=b0;H^g| zH1Kt<7qsBf7ojT|UdN(fE;Fg|V7QQrQEK+2&R={kIQ0vYgXiu4;pjY|lI;Em{?^ZW z9Sspd&6Wcc1QpUWE6XiI6GRk5OOg_^Qd?&0&CmqYlqIMQ2@Di9Ny^NI1f^)hSyonB zR@$~~*!n;HkLP$$#(nPhe(yc!e)xQ!yZ1Q0qO@N>wx%^~rzidKvUidy_?FYm_~K{Q z&G_sIvrYQ(#%tAG*LVA$X7xYZ_N;AtlIgSZ(0YAwfhA*_)2S|TRekM+PrS~Mpo80T1{MV7^o^-$ZuNKyxvr?tlMcHjGbqPU)^Ca{O01?AcvG5tDum@14*-Y9iHUtlJPNqa%J?Y`bKd zj{Y)_cc4715M(*%xD`6N1Us0tW+>V}u}n5|4UZ>aMV3fbb}ld3e*1fXf0q~q?p8c8 zV0=y<$WlpL#+LIlw(iXX!O2)q^C5?@7vVoZ-E7O2mbCElH7o}Kcm%Vy{2`n9s|0;4 z-n0DEI=?kJq1B9z(%OL#p9v?g+c$R&7M{*=VgCrhKZcH6u~$qKQQVbDfn9^K@%y)E zVB2V6hdvKDHh{&?Sfr*$_nhl93RB-Py~(4E{C zOTcoQ@Sg5%)vkMPau{!J6@S0md0cu%H5_&2?LqvvcFSE74ky@1!hElc2;9xzLPL}s_#pWsg*2XZ6Rw6-rSP{So_aZnF zN*B=+iWoGCw3(=o;Yevk2u_;R1u8V}C_?fi60S(3bTt~)X)-vHCy+Cnn9^p2R867~ zqrk{GFc`-zhiaR!O_)q6Q6f^3m1?|10<=maWfsL+=&?$!kziDE<^A$eCWGh&CD;|} zGwVTlIZkEv3@SWQpBVzNRl4d&d+eKCtoU?6ty0d$Q=*U{o*oDd9!>z2VHSWPNIG1C zMiM+c;UcA?DKm)IC6Q2?b?zW!h6$m3G&&fQS>fVp9Gx^N z)LRfFdny$!adBpj*sFAc6dxBDugk;Ss2`+gw4+^IIg6C0lkf>7HcP;gGnE9CLYB3~ z11@UTs!`!MKBtEzk`UEoWoQqEmRTebgJBYRuSlI%0jCQ(tLJd+!G?Y%A5Tk@AySQD zcs`!)#JPI}WRK>mn$=-SFac8M38$)g%rvQ{xrt4dP~FU#(r^L+rBP=Vamy{_Of3+k zUPG^mLI)l&M+c~MTDDT6(6L$RWUdkn6)6*BC{+b0pJnJ|DJ2x(FQG*l#$JsBnv>AU zMx{g?1R%;UQ!{n(tB9&VX1{+LbKA!3U5*Sj6A{|7+<%f0@INN)Ax>y47W{4Ep z(H<-<9Zw_I*0NIO6mDpvVVKfrRI|2sz>b4~!wU^f4(?h-1%=U+P$UGp8-<2@dH^5D zWHD2vnkZBhN-M(g^*}u1D7w&89S{bk8j~Z{wCG?+rI^oE;`n?V7fYle5HKLIvEo7_ zOo_%z!VzMLLfhLZlDOC~)0(hK7#c}qry7mr5MGJ{h{qg2qR|9AU7$p;Jm6GDvL`&0 zNn~j0e1S+64(Ey$o#{q{kwjw|3?>sFrD|@Gp>&9_dax%R7~3#|L7x^L3xQHJaBiK2 zgeSI8!)^8U$smCcRf|N!;rT`-8cr5-*m$1&?_lr=Xi_$FR80`a0S`f3drcHBy&lM8 zbAy&e@NfeoDcY`JZyAk3ODIxF>xm3FSzt1Wfqc-V_|PdzIZJ}$M@kh<4t79liS)fD ziG&JFF*r{`h9gmQvZ6D!Gu2dBVpwx3NR6SyU@UD;T;09Z_$B~xOA-`g$R;& z3iS325YMPYHKoabjAVt?(#7a>vXUi|k9g~FgwPZsQ3C|tyG5%MsZ+I0z~TwVZsVNQ#43!L811q-!La$C2T2y0vh{+&*N;&~a z33qQ_<-}tFV?u;;`kUCH`Vp*vuB1}w3B@2jAwfn%;5ca^mO#p(l%bv2zl&NNRHcc6 z64fXPLkN>-l3k#5IL$|!R^b9sr}Kg7)|93dk?|y8eutB3DBy}sy{SqOoy6kuIGu4+ zsw%|BJDevHMVWU<86*rFkJqP#m~FrIm(tDf!~e4z_#yt|^pE2|_~tW7kmGk6reFEx zshif`3NG$666a1oZD|BQe|=!|PI8eWm?xD!{Z z9icU1K1~cHRq?NntXOqV2{FQy2G zr(BgqebdvwFWg@UzKZ`=ruNxx*Ed+wxAdB>m$yhR-c|TnIkQ4r z5^cDDd$4mO^WlwOq33=I)r^yqqrU~e;8pt;kObhWn4F%sq}L{iv^DJCM`H`S%jE5| zM4O%0eZ8w+u(Z9~eMNlV2^uDf^(5)2nZHl;sN#UfhTH?dZM4|KGR+_yW{oZATPmTPk`$NF*R5%G-1Qf z>yU}dXL8JQkrldRCvG@FHzE*3K%UEDe>}nkJxW|M;R?F7*>BUt$MpnNy?-CB)!v~R ze(hUP*_Z8fOlW=;;qo4^_(Pb@$vA}X!(eUb!RQ<6#jPG)Z=dqF4xaE&pZIjRh(d5# zaM!Yz1YMEkHLa87R>(fM-5Xf8=7a~Se&Wz=FIuf`P9N`Tv9ERV)ALuC7w4WcTQ#jk zgkA_#M(xy{AzzQj@2=?>T2NY(M828gEPQ+^Pf2v*Weq+Ly`TvBH}Zn2*K0Cdy*gdG zI(zL+#Wz(@K~1_P>t4(KW?0S@UC#>7ccG#{eTB&igI@c>^I_7Z!ZXLqkTbSM{>!q` zyl3g{%p0FIGt-=psRr*IoATvIP7GeBxPNaFE`-K4IO10CT8AH9<9#^D^9wAmW&6=l z)s(0&DvY zv5|k^0FCPh?&c>rVE1Q_^A{;&TvBq^@058KIX&z&O9qZ)XCmtdsDJuG|14Wu?v*Nt z5~<2UYSpyDqqBC3vvJ=&A$#ug&Gv$PD@$zMSZmTeN^f!I-Ro5U_H=dn*}mWLp5{s1 zPuh@Um@Ojm^>5Rn>ER6B*Pn!Q>oYjXQ*BX$Sg*oqN{_qk?_<2YO_#2NhL*!Z=ikE* zS5wj-KsT05=mZV>|15@Ff|&=GCjYbapMO$kt)07e*(eKIReqTV{&#pMPrUPx)M5j9c40oDFU`5a!89;YsVys-aW>{!@%LF! z%)$AQgN)MuT0;8_&Ig|0EgRL$;^V`-i#AQ5#>}L{bnw zU;H|EN;ubdZG7lP_f6g-m6;;LlfJ>AS;sC#F0qOSALzmu*x*(1@9}}+MOEs_KG*5R zlyS@YMT_iSPwY3t9t<0t+vr@XV=X)JEe3Y-kUO;#a|RRnQgZv4IE*-qAmO@-;N)I? z*435@IM%NAoW-V-Cu8qqhfO&ZT=Mq*GJa=LY3C}1%KDCrlY>0>p^9z{V1JL6L4LtY z#e)O7w6XwBiG}$MTQ+mx(LG&MK;hPuKaaB`E8ou=fkEyJ zUimWWVi3H@D(4I%W^eMi5uhSxGIUaZ8eA>mPm52@1(~V_DqWSp2 z{V`1ua4vM&IkTRIu2GwRK|SHE`RDzw_^caLhUC|+%3q&poy*X08NcYUuu^ATNpKwR z)Nan%V#2!W2uREjVUxw=FD*4g^daBb5KNm~_cQd)fsXej7vPuW{_)9&cQ(C{Ouwa^ zpMgwoOuB?))Cf-;Qa{A52fcd7#$`G6&Nol3+2otDI(LjFNO>1{J7i)!Ltay}|Y*WAl zJ9eUY4z0;4NpE$oqgw*5il&Wo-hzf9)UIV%eqvz5jpFj1J^N+`OdWi&Dku5yRrj|U zo1*A&aZKNs`@*V}pAO^L@(!~D=YB??f!Z|Cj?I$UY69zrj@URSNzV+fe$V6zP|=vj z2|o}xXL5hvsLtk1TbWb+A)AKib|jN49Td(@*t#&H!(+!-y?A2uL0jaH{)6sesli6h z_l#rj9_+DRd)0RCaaTXo4f8;F?2)1qQzozbAlQex3`rCb&fD>jDO4?Y?kl9ZVQ&s~ zo)A{<&S#dyM@Z66<nT6}N$}RZW1cS0HE#Fv^}Z;( zGwbtC4xS{d^W0s(W9P|SCH4?~$XKVvfh%#Yq6gH3*7~e<@1IgvDtTrglFzHpDoVY zp;(ohQXw)d9vJQoh~#95$Lyq}xy3K}2l$)rK3@66`KF;azFJ#5;E5}KQ|lJ>J1OIW zSAOLvn;!fpZdq1}^W*N8h9|=aR|1F8Qq=zF1Jt1%9oY1$V*ZPum+$D|dD|+-8*C2k z%zk``85c-z@K5;7_b<5s5Z^ID@VA`=)Jx4DuWeK^tbKgV&qZ(H0_1@pkXg;}%3w-1 zA@?W%x~MCkwZ&>q1zUsqF2@|Fcd9odhQT*ZPiT?bE)|tRFd%Haa~B!CYs$3nO!w`6 zDhnkI-Cc0LFrHg=cQkvJ?NaikvDWot^eNM}d*H{~t+~COpDSiB-X3`^*?rq#y!!*R zx-oG_^fQP-d+S{8E%6oc=b6Nw=7|n$cfkwQGx=oSzOLR2UExy)F9yhMtSoO^vNivS zCn(|m=R95Xr)g27=y?Z0)-tFIwT;e(S`g~g!V&w}03TWa?Yhn>$*{TW*k zIk?2U$4!YHLT~xfa%MHGZ0J&3g}kuO(RTarqEl|BmIaNlTNiJ8TgEqsZTU8&*jQgh zi2$dTpS^VS>(%ETzQdVGrWo4TrLJl&|j%?!0?^QKk@p`qnKwW;!_8Tp4(HFM$`U;E_{Bf?@yu5F8JZ%Jv~GrsP}pmPJPDH z?MBy|GMizP734c-uhz?le0t%Ip1HC8((1h+D|J&=QWE0yH}jVp7dUJm@cekUEbu^l z*u~+v-wW>?Pi^x!uT2=Lu3j)exStWne68(^y}q~d>;T%5>hm&{{HL-3QnUZhrdbZx zQkL8jZOFrY4(hsU=j_{rX>iUkJK9s4Y@*D(=cu=80K2#|lJ} zBGT$kKz5veSpZ?%N{Sje3vzy%_Z)x3vBH@!wBY*Vw`Wi={X2t$>dKwo9Vqv`Amx{7 zW~??h`Hrdgq+<7-H3^qu`_tma)B7G@D*urptMIT*NbCa#Q;@~1!+Qf}%&#h_PhAtP zkK*so7d@kGI#1c+zi*@2ab#!6(o_AiJnft%`xRL(6+?ar#c_(7MBib4cx?grA zQ8N})s+oR74u?tC&0H1 zV6x@PD9Z~qdmmLdk#i;Yk3Ig>=yuk6%4yEH=)g?eE56ScSL1;NpJv>?GLV~esIA23 zZ0Q}0$GY{;qJJ$@243-7cT|y&G1i{A8!^9^KzH3oPqI0W2mkK>zHgh*JM{2+(r3B% zvrx27wxMY6LS=Uc_fOZY=C=_C?>&EWetv`Ql9}fR&&@L+_k4FY;hBa0_}6CV7m*MD z`KIe!`@b2pJ)ufcwt!`#O6U|aup>+-W%&>_QFw|{Sz8WOvtVKm4?6^nLZJf?huESO zgLsCNG^vb@ClG+WcQi;W=p~Uw%|bg1k;IcMt@1)OOGC_3iSR-_o2*d6pmZdS#FFqS zL@HUTsUY`D#N_}~6MImI5+cy3M7g1;&~UfN0}O?e$rLeEByEW@XO3W4EF_%@$8qIM z#YHHvL1kiz8aSVez>v1sfvtFP6m2h809+PSlpu4LMwz!`o${pNI5FR7=)|#^y&|ca zqGd4wX4fnRK%$<-WXb{FpdA1s;COl*0;M8~Bm_7gNhdNG;czusu%W`-i1+leXrE)3 zuOCCB;RH%q-U9O!I|OL4}TnV$fUx z*vaLWVG^`7L}mq?05QtFYd`?IKwTtZflYmLxSN%s2a{#b0JsVfjPyi<^1VayFfiyh9{uOy|qHHl_XBW6@!s3P>~MqX@^Ep0eIkn;}bk-Y#f_} z1rAZe(BPpdnKA%(a#FSZ00*#-Y|nsmysOX{8kUxw&@64KAa`>4TnX@;FOn=0!W2e> zezYq=D~&=)n_X-MJS^niEzRM<-sjEiJ@KAMt`a2R@;do=a#xs=#WI*AWPqp>sYF#! zzIU6Ng}{rN&sJHtukx}3LqtZrNcs07VYRiy!VLhuQ6gL{c*b%t4A12;N0mwzpVP_g z!2nECpIvJrCp1ea%KR|`IYEa2#u{!1)zH-x29-vp(19k6N(h&Gt-y}M?Cm0W!adPs zBA4$9U}al9z&oAnsT3LNBFjWikO5$r44_OLfD17IbkZ_EggEyal_VdnHmwK_L_?(` z0OxFaSGZVq%31ix4aMNUZ|LoUDwKRqKfrvrf|ONG`6F095)2DN;8N^kE#zZ%P%!}} zVD>7)Jz$2E1#lW0r3rVvE2AN)bc)6WVuzGOfe8d86^&zK1roF!nT4k-l>$vT+QrIf zFp+645Qr^6CP4tqYGDu==H4=tB%y*9M^U0AN~lB$Q%XeIekB470hkVutpKIriU|Z; zQyeffI2;g9BpM0#fRaUw1R0#pr#SHd{HXV_)+?H6Sy-Z*9Ry9F0e36ZNq|cMMqI)m zO3HX$NH|4N1gFq)LaaC}ywI7c^swR=c1U#yRI;@Z!_LZLWAJ1;QKD&)u`zoATBd5Q zgHeIT`FbBTSq`9`mLe)n0AOF>Q8YmLl0jly4YAP3#5m=#!U{W@qb$k*^hZF@SOk1Z z76Aft9%TZI-nFyLrwCEl$pTUw4O}|`4sfEjX!FzpGao0ey%&TErx596J&Q$#(@|P^ zuMSolX$%vhnguCykkDprnZ3}?EVT&C=_sPQz<^Z(NtC$YlW6G_F$y@-5bgru@&sK3 zI4w7QwEsC`?r8Z%SL{St}Xw!uU)^t^cKtBigf&fpY!(BHN z0rVD63zM5Tok0HKMC!P7PA|Z1iOEQ#QDD?(rIY{Labd=RX4L`5 zlQ$W8>hkfB8UegK89Kh><`T9E^G6nlLQZF8ph!6?`Tf+A?E~#~_=mai**}~|n6OM| z{CY;>fr2-YpYV-5JL;{6yT;sWP*vQyuId57gR>2S=Arve*ulV_TBq&sOZz~i#8SN-x2`$V+zBly&G zt;XJ=&Ypvn2xw)HrERF+71OAAd{{*g@@CnfuHfIiHTJ}*OEn3QA-7PKWGxyB>oVAw zeTc8Qw=S|^T=nKydV<@r|9y$R`=mB*`#HC`CkXnx^95UnrX^M#x=_r^O#Pdrg{6?=0EBt)t^Pg`^ zBi{$uI5bdXKVDEauexz@ivIaukwLY?T?bo?EpB(P9|SELfpJ{VJzOBz-{KN+Kl~-y z-N04vbpG*Y-6B@UP$CL#K)QdKzbZ+eg?F%}f8d%&j40kPFQq+tIu@YQ|5N9(N7uIc z=h~j(zCaQ&rl;)Mxd#j3hsys~6<4O79y6$@`du zSg9T@S@`5D=<>RMGTz4L@HSFiYs`Ir^al>ac3W?y7eyybbd}Gke$HGcLwIDMoLter zKQ2A)5WMcb2d_64{gDIQvwQD*MBn}soASLII*K`^E-oFvsBQR~aZKr%Z}6Z3+kby# z+}wEc@Kz$aCg*v=x#F-@leP+Xy)WYxchACmB2q4=%j9@+jzkJRpf3!8U!cg+!KC~;h72KUQVqI&NkLqH(Cj|1)7M`jh`Ke07x_&>r|F{3v zx@QWGS(%r!PZc!)H})pnTI}g^#M9p|V6v{18vJv}oOpC^Pn-4WhUrABvr{inDVOJM z>ep1ixx-OG?(EP)T)T(=B)5TAl+*8R%gnY|mQ%Ln;umi2`+{8?##b$S%Q`E+__6kl zclkMk^MK2=b-7b+&!UW#m2BXcFIc(W)U3bLiSJgcf0=foi7uGFn`LAv7foC{#*1owQB z+Ag>pxwJLCOt=!FZC&G69Q02tIfgK~)#xSu?X+piFKN*;ud1$7c^7O-53U!#+FO@g z0V;ZI-CD({v79s3f6gwyc6H`sX=3Hw74Bama+|b4QOi;LZxY0{ZmgfU;zLh^>o!+R zEo-ZART(-FpRniNR~0T1hVN3cNWPVzNz6>u2>rmZ|G5>dNEnC_SQpPEZPlum4Pz48 z;0Y4?t-5a{Z4kwS_T)iCph6x+L3XF}QSg@Gc!Q^!vqwrpnqB#m(3CwLb-6x!;E!A3Qkm zj8droWdA*j9#*s+c06pR;+zz1XSH*^c0(Q!d&XN+Vw{B_zjPp zZY|D%CYJ{l?tN=$EuWM!D)!CIitU#-5}#a#5$`OEeL8KEV2xf%jQRblVAFC7jjR$p{>kO%e`<$+B0<}n zjarcJIHea#?o!Lr7PgVC`oJJrd}V#M=j^M(`| z<4!J;nj7`AC;Va4g-5d99L>+Entfyb#Jc)U@WQ1@b)cxK9(rea8M!38O&a;*Oma`p z`cZb$Gyh7L_?17_Jg6p8;y=QxGPZsm>ZhK2AcYbPZG+&tBQLRBP zbosjzn3-twzr1NjSo-wFE)XT`g>;Q zRtLSmv+3ULoiXvZYK7Lw}Y0n)Bu3r}Tu<#3`H)PKupYJa7Y)>c3 zmgv~~NT`}GYwHcfZ)-D7k0qXR9bq`sEB$cDUk$&zAHJa1F5VRPTlL!;9{gZx;T!|` z(UoN3hiAL8Gp74T2&uGjHy5*ngGZg*0v29puMDfrslDH&v%3}G^dGMqXEC!sF+4w1 zAda$1xO%Z^CgU79zN9+qa5!Zj0+e5Jv9-u?@=do}^#%GMlDgVoAF;|jaSZr4}Y@b)P*bTe+Vn6kMFa3TB4&8Ljqw?RfUk zAzkzY;s zzE7-fMHl3s@jYMuOk3Ys?z?v;c!t;cdOwX={QlA#MR2v{tRo)&xi1U>Yw^W)b7qz? z!uM$69KUo~Fl-=a58I!7kZglr!@pSUyLt3PimS`(`M)SnpK8AO?#X+5d>ln{O7PNT z3vAT)F?D-Qq(}LK-5F)}C&Ead@85Pj=Tmg_dh%y9XwHTW4bII(_@+9R$D)R9icgRZ z!6jJ2VLYo&bajyvqYFH?^~$!$n+wYh!c=s4f}Yr%yRDq z4@~xhJP0zk6f0C9QJKHxTtW5Al3D+ZX5E5FP^^@nKD_s>)=K3Hi7TJ@Du0L zef061Pv)(4ip=xiP>k z=WVAFv#=Qsl|#3#AS!weu+S%vOv!6zcZrTF+$Gob~6Nj!t=Ai2vn{k4=tM zZ+>Qxf!Ku`WEH^Jwy`Lt!FuPJlx$i8lV(^V3=4E5yF;!|ufAe@v!Aa@YD z!uVxRzdNCpuiCCZrc`{WYFhkG#oAd{?A%)4&{YC;o~qdtr??XF&?fj*q4HxgZLa51 zt_^hk_NmTF?*rg9XG!UslZw$FXEi?@ilqe{L7yF@NntwlZT92YWw#>Ke(xms;o}mY z!~dp!A8H~7RC|I=`j&LN6BeQWV$utL2@(t6g!g~^h3?%qBYpuS&1w4*J7?1Imr3p2 zs@UX<-N)*tB#TFbp68r>2^yFjShg#p3|I2aIN;qvCXP{m2fQaj@S%fqGPlX$cJ0%( z2^;C=q(sZe``gF)g!J{E*42^k>u7NSz=0X_ltxkMqx~zk;>dIcR57q?;!oI{2ll@L z97KIVyr?~q|NS#V*YRy;ZC5as1jtdg%1{$eK8AFG@r^hV8`!WXBk43`laHFsl8>e; zQBj~gfTbDr7#acT1&B9*)8xeC@i|6}kKDTrU_B*(>2$Ur%+Td*9}NavJQ=E4jcTR? z@TrXRw1~?Sso4oNv^ZF~>t7EgwTaD=NQ6i_T|$KmdX+FfAAv`rl&*jmriN2pT=hQE z2n#_SlB%e1v2$;320l&!XiF_R!9B1Ch$oUzoC;-`RVWEXlML8$sv;_!l+F)jYE?2g zAR+;ff*tIwNoZE0QFM`*bGHaCqRU6-c%ogIJ@zdcqJ*Mt%EHR~#VnY>Xk^K~qd+31 z5+@i$0c;ECxf2&F(tvS1CctKtR61E})H9jHD4=B}fnaCenXX77y11GoRAMr!nJ7qT z)^_rksYIboio@S8a9PY0AFWBEs-UG4Z1p})9H}fpdJ##mbJw9^fKnq7LQu`Xf5XGX z1U%qJ5s+vPfY{IoV6+9d8cU*ui2=3=xP-tb^%@mTY^E}fAW@>xARa%Cz(?W>jVw(# z9x%o{;RGC~6I%E>Fjk~ZD59eAfCuE| zVWAWQ;8UR`u@Q*OA|V)07u%V46wDgIu)|m|9G_?OF4Dx2nPT$|E#c;dB7hiWW%($* zE3Ea>C_uqO0{piNjbQtP1AQS^&mnP`A9uxx=wu=qfpi6;@8N1>84XX5*w`nU14s^w zN~WdDdpiLdk=fkb(hSg7S0I2M&~m6kf_7K(VNe1XR@j+NPiP@(v;ge`V4Z=fguwtP z3K*5%5>Fg6H5?du6F;m|8OJhok;zIP-x-jW!aboxO-qF*k%rF~LZCP1%`yu9PVWYhYKx6vG6(&XDl`7uul{i0Z4j)5ZL9+!t=vK zidq`Z9uUh&H&u8k>a&$eTP>%hUoaABrb8WwDT+F3zI>1()}V$-PQ3 zOpd`e*$Zv;AF+ZI0ETbDOXw0D*I?pude}^sybEw-kuE@3t=oy=c(@0glvBjzdbcQ= zB}g9O?|P+2u^An)FXN1)1G=*bSV<&wr^5@(?LF)O z4>?uJaLUVE+U`%oU@}9@0M9lYR%npQC|sq$bS?kJU+(w672&`0e>eUv`n|DPv8Y12 zM>_NJKELkgxuWLF2S1!9{L#F{Utny&xL({g#XCE2%rf^-k@n)fopWyF{AS8pw|{9O zIDB&c^4MAHm-coqLo$|EV(1K1sPVk{4xPn;!ofe;=3BsHA&cI8N0#*-rU>G%8f8=G zU6F>=HAweoI)}K8DZb`x-ZovM!~T_-NZdKT;OVy#G7y!9hm1OJS_w(V39p!g=X8ZXLuQ zZZju1MC^?<>_73+DtLLut1%vO&)Zm+0qc(e`ZHU{CD=phHShLb-)Hkw^|jIQA{wro-t}au z+U_N(`+H0i^~V)^_kBg}iUO`_tnl)`ayLPzpLn^B zoOj_IN_)OgGeYBqa=-f@#rvuU%Qo%JgYvvqpsxiAc2gVIBm^COP5AyiCnu_FVsNW7 zqunL!zGFt3(F6SULEq@DZ_&lrtfcRT$MZ)O2h~B+fi3&`n>NrDFT>(3mjn(Cr@rvC zLOx%A(;W7EH#%hHIqIvOkv7(Uw22LIg0}mNJEUBn9jz~9#x7OQVy`(TolOhC%+stq zYtdmk?7|M)MD(cQP2B``BcMky!<$zk;lZ)Tl$`^Ayt&Rkt>ttR(fzohIrz z$Myy?;tE5KUaN&&+UkN81ywdd9k(i0Wf#0j@4hCfHBP!aM-MnG5X^bFv~12|)CM^i zEz(8rKy11nEEu@)>T9UukJO4SupiP(XQGHjZEmn3A+hM^jW-L)fy>u^jayB>;(V20 zzZGnD=SJXjP}t*i0U@x?>*nSppA%EA;;j7#7~ENBb8susPV0V0+=ulSFPfQTE+Dv^ z|2M6iG5MJdlGu(seI32+#P;!$?3=s?h^>LUlVE~Ue5>V;dkZ-Uo5+pzS6Bxmk@&); zteG9WJ+|s3G-KoU)8TLBdn~Z&pq*sr&sR>5#_zU%n3CRpp%;_vVJcAmnS@=LvAE#f zevl2dxLLo>l4@_ew{q}}89cS)@eOz$1^l_iWuamL9OL!^HnaHyWo%8!wA_a7j=de! zdb(gi>c`)mqZ?39ZsMQa-Zh)L4AwmmVz$if*SY--^OvJXk5&T}PUSw=1Ircrik7h^ z5+k7t3E$fHEMANM{3^e_EOs#kA{-0CR`xDf)_@nJl3_^31wY<;323GDp-NbQyeCiT zAh@zN`EWbT8F@PB^t>;5AGTs@(8Q%g!r`XHX7e8XT088ldlIrc8?!>TV3U4NDcaSJ zSK#|3LwG-Hujq|*$4MPoI!=L1c$NImzQD5d)%dS9FZaH4^>fv~aOGRk&+e0PvsYZK z{;w(SHKt}_LGXX7*Zi!I%HYqhV8ty_INd_nfb;2&J1#4T z8!TPgWS8xh?f)IWrCcEY?CO9&$0$krod0`vl%o&+NUU8#u`DtrW#mo4y;`?@ z{dZj)f**|>E+!lQDcd=X7_P8sW%=gf4YXOps53hPMEtF1+&&~DHh2C!M{9YdRH<); zN@9=Lp63X;Zdal6r`$HV-J#wVuPAnoPac31@@@V|N|QebWFUb#(H z2s055Rk7r-nC+30ok+^h9^9VncVpQh^^ez2IL_L?G+S6Rdyn<;>JPt;;t8ZC*e{Zq z0e^lp_y54YPRQ5ON@hpAOR`Po?+n48+ommtPuuR&k>C5edaP>EXRLcHdH=}q z4}PalG}Psi>d~o~PX|p(Mp5OSWv-35jnsULyse8nrq&EbreM!R=FQf4tgi@-PyO^W zR=<6o*LH7^m9PkNY0~U~gRXG5hV? z7ql!n7sCDt``^Dio-NB;=EJvUR82Sq=$oLQci$=eCfMDAOpCI-_iT?Deqa<@>MhSp9T> zZurTbVq5Q9K6Bu^2K}we{nY)dMTeZKJ?e7SEsx}`{zDJ) zEgJ^^-GR2CMP z{V=so%YU-+ebTA?)#I@qKPMhM=j+Kx2SrcZLelp2c+rK{H|#6Po3R49eZ~p>!8xTT zX)_O;2;ygdnIlhEVoEyQu5mr>b^n$0S3s7*vlTbLe}NMp96R96JY2cq{LEu1&eY(< zRsf(Iq3UO6RAryb@;y+SN(+B)v(5FYi8t$fXLxkP@%_O|S(^qu-z8$G-!lq7e>V+u zl_q9XW^xpHH$x7dk6m9z)38Z33!>S_%&g_JMV zaK)T6pWN?#&E`MYHf437_m5Y)NAvyaE8BEsDK(*s%OKGPo!LC*mEHBEgwV&ThTZ3P z@$B@6gJ$neGHW!`T^2lxMt|9xnxb8Rs7aq|58W;>e|xA^;Pct{md^*dFwygTS&*Ma zek}b$j<3;qPWSr}o9CJrcyW2$gPsh-fj7dN1op`#1GmyCX zej<)Ln$N%2b7>{BCtoNh&7zk%KTLz1+08ZUM->brswBx_yZnNSlV-bi?2n%|1e`+Z zytZfy=-cCBjPkF9gRHJyRN=SD>8+^CQ||1$!;nBP9UgVMk8*L`GR}SD$0zJv+Z((S zuI*9oEPsESxK|gl&wk&OV8_h!kn6`5-8c^E7#Xn~v^^!+HfW%~zV+p*KCg{@TF0W9 zwhk4Pzf3$<{xtWJUo589QNmv2*H}4_QEL1c{p7h00TY;twVGFoOP4nE@hP6MA~fpR z!_d1ns`ga<{!o%S{h|c>VCBiVPUn8Vj|(@4sKm6FueRxyG>i^x z25$JWASC4EAcOaa8Iigph*7V50@)K$71+KJJAH22abj5e`bTigZIv}SZ#)p2E$Hc6iMwd=YwA#?&@639Mw}4&-Jo z1&%*(>3(^sJvHX|WK~<|55Vj^Se0VBOb;BUOUa_ilnZkwfe)a=$M@o6~lM<}_$$p2nh$j^a;}iP`Z9u$1{oU4QN7!b^l-Ksr z6zeYPgCB<%?ZrKtHQ9*WIQzRirQGnga0C(ZHTSMl0xy6Mvg*}bnjjN0NY&ZN(yR7+ zdmTUS0;9%@7hHBemHa@%&s-5!9_Ysnboal1!YX46#p7$}Rr~e1KR>!ll`lM4oA~qP zy4XsxgZvNFTE5o$p6s+pMV6Pf?kT_f#9Vo0&c)b+2ABbSAX&Wf_!dthwCmCIuaXwF z-&rkvVmB&xeZ0eZnBR@lqBki|Yp=TJSJyAt%RGYv&!?R(V4Yl=PZ{s?yWw$0x3LWy zC$jpg9`-wrJFyxzaN9EhIV!b0>(uc)W!rB3FLGQcwiGC|c$=N@&m>t4Cjf>HurV_V zm}%PHCUzE^1r#~zLPvb+0X!y8twRGokr<{XAXpNf9t$S~+ed+c$}@>b+AK?_E18)x z8p{Od@&J3Six5WstJ9EyU9k_9B4^$$0-%N9xl!+}5h4Id>}-J*Ck5E?)5#LDn&beu zEdcsr_R5($M1n?!B-7k0fM-MupnMI^aPdI1@NhDnlx44p2DacGM!6Ek)pvRmxe95E zN*Bxu#*ji&!|lwCtT>*DN9J+MFl?3yU_3SEsUa5LfOG_~7+}MWKq`RZF}jEbx7HIW zGF&zI&x=0~S2K2_! zo}6Sbrx#EUl?XlpAfRRGWI>a?3=S8w@POI{%1aGL0UVQ$!xQWvd;*~46dL~`MmQRd z6GI8ezap5CiKUU1%qD+LcqBki0h(trm_#bAh-chzWR^Ek4ay%$2Z|Q8y=RLEAdbx3ic~V>|G$(DbeYo&R9m9a0Mfv*#5fikL)!wN zN20_F4B?B2aG@Pa83t7M3QU|{6Sou)G9h5g4aMeqEnp7Kd~H+;~R~}6n`Q^OjZM*8VXR;CV*E6m|X;<38=*l?Zm?Y?kMj}mFjRn zV3om{Et0SHhO&eWbQ;@IQ3 z?XzvXovHy*4C;Z#p|k?N6&^{W&=Yh>Jeq`6Xqz11U@jjKQ{=j#%A6Sv6nHaeWI(-C zs!U#em)tnQk8gus{t@dH?$DNaV&|T z2}|>qfq)v+ShJicBwzpn*i9N9+C^4qy{nw^?za+?;W!{@MiccfF+rjvo(P~k76X(# z1NN#`{Z~O%XaW0BqE#4x%1Hu<>s^JSp_Bp%*%WHR1J$wsg>H8sv2|LPy zdzyEq6{*x|K!_E(FnJ0F4#vk(YL!5-v@%sgOg6{BsetC_UEr#>kA}NJ_@Um>U;>~3 zN`MssD6}RDtpGa>D+6@Tvb@v^z|sVqvm!S;3o!%`-2hDyuqV-WfRFmu(&Lj|Z2AGF ziKN=OUnGlBYO)xOV${0WrsNW1%{y5@qqLY}s74J`|8kg8qKc@JA=p&95~YLE(wdbx z3^v@3%h$8Q#Gz865X9s1@dHmS_-$XeJb4M}T*lezdqyuy8aLpV;j%9MCNa)UO=A3Pgq%6Z6#iAUkT*asyQAFjc zC>@S&^?Un0`lrXE#b?`RpU30#+VlB(dGknADi36!G?Xuqk3rY!ZyYeO8r#pnK$MtV zK8**cM;*~X3Z4hPobU!mnjBOV4XD21Zo?u30S(?iaP{?S-iY1>Ln_+Xey^evO&suH z16nw^sse(q!Gs%VBA2J)4Iz7!7hy1X3>?sdc}7Hl5DJrn9Kiz{RDf2W0j|VU9yksj zo)Xju&JB?8cY^K$1R}jd6?hl;#Yi^1uB^ntgZR#P4A8*(;Bw33^;DB< zbs*S~!hEFyDI^0=&r>0ByL;ZG+VU>dp*J!+Q8fNv$G@-tZTa^F{Q1^c7P)zQukx>6 zXmXB8+um7xaEVcL;NM-;RA0+2>S@1?qRcGqG<0!RuJEuQhXUk+eYYQ~__2HEq13;G z0NL^+yDpoU{TkKPo|G?Z?KP<8b3-nyhb7RxhQ59iF=cvHR!doDPtAtD@BATa^TV*v zV%|~Gy963z8G|woe=N1VLPTgOM-H&+`6+D^B!)mk{W`UfT$qLVUGF_#p&b`4p4-V_ zp?Aq!2@*Do*>4uMdR-T?U~>4NIIa9hF>1%xuOXYyDIpy~i;z&%Wf|Qa4*swH{(&mc zC3nc4a+BN@D3+BTxy8Pjb*Vt+ig{aIedMuM<^J2HA(#IvJ1Ay+DKV-eiCos!$XDAr z(^0ta-$U&7o%-ifohsB1711WmYTvjam{pTI(+80PRSC(_Ay{qt5VRYYc+mZNmTLP` zlvGA~3GvOKW^?+bf0#pgkT+|Njms2WmlD+*IYd{-PxG(_5*eQsN$$Ew*FrI>36Fji z`aZO}_4_^Zy4BV4-O_Zi+tCEfi!XUM$6b7flb+J#WyC4QC@-RobIqSNg_MK?u%4m) z2enQ%M4jeH1S&#Ikjr+Poo^hWKJ5(_?U{I=J}tTjVf(eb>{d31Gw;P>=t-z)x<|KT zdYQ+*;H63zuFsqjmUpH*<~9$M$fQn(txDZ}oa$ndM?B=%s1$twD;UqbYccqO883eM zQCLPqcCb?98@lt~{}!!3W~9BOMbB7|(T}`aqY0ASbw@+IoNksaif!r^zbmhdwzqWD zyj>yX8(C>bvdc(Wy>;59=;UL_S88NZpddREqQMVmQ zO;Pzq@BZj>uBBkN84i6+E-q2z$~N=j;~_zXS>uw7E5k{ebE zKi}q1xaV*GjgJyUv|3w)?CO|92RXMP_+k2>+OeGn$q$oxkT6@JjDDce<6U0%s(me+ zZAoMh_#je85}qMkL+rsv;1d_m^2rq zaIMegeE&W3KQgE6zMovon=sx1?@_$pzPoIVBR-I1lPS5E9@pp_NS^Ew`=%^9d0+@x zajY;AU!OigtL-vrbPGYc)*Bi{suFKMM3uSIARa$j4FP-_PDw83d9iV&J`Lv1M z+X)*7IVJ5($VE+0*|$d3s{o9+DfFDaS-CLy2;+aj-X%9ttHf4Y~cz!U0|5G62t?&P4&^9#zSh{ zom&DzI@1)KL?s%=TDK*2zCSFj22nwG;?zZRoj*uPT+#j8yT{5jMMCVza`s*u!z0km zuM3NGQ;wZQ03IVQ)+SLl=EOoRa#J97>yPG=oM+-H(p@eIT>nkhdkn7~I$e^UlA#`- zb2v6+*Nlet0g*4{S($2u-)EJoZnGpy#SZiyi`rzN$o%LX_@8-X+U*k#N<&ughjJ3t zGCw`*y-IDzbzclg-llk`|M34z3%ESka;T>E`<7oYKF?tnN^|a$=%ojr(DlD4*XA$P z|K5d}n|l2&PnM}?uv=t4xI$QUs+KXXHfs<9f!0>8z0>-u7w|G^s*3vyT1{UG?0Gy? zFJ>#hb7ej@rx$AYc3EER5%pX{G8q=*1_1xV%Na@q{9aN(&BsA^bJcRH%*}*Q^{P+G z2o>_gvCG?nano4{h<-`ub53{KJ!`b=h3;qLf&nvzYJf6{*MZ44-NDV{92t7)P&eOH zW0Y5sxoXW4PjQIRUHqiS;C0P(z-(k4*!m3-KWW=hHg6U81axXctwiM9Kc8FQ60r-? zBDPciWkl7Yf>Cjyh9~v3)@PhEJ7JTNsUqpYeAD6*Y4(ikY=x*;=f^dHhB(?tqdj0e z?+>g;`(0W(^uP}8^loUF|JS>TAzcRKK|jsdrCpt6T$}HzDUSxf23$%pICx7oD0I`} z?d@_AUMFZ}5-iPm~ zk#r-bHRm!`S~OMUI2mgdv?tM-9_7;DctQm)7H4Ff6*=Q38eulmnaz<=J3epo0jX{$ z&az9osc%p1upxebhqmU54=~`8`kJlM6fK)Mfu`1_g!5yM5BHiVd+3#%xIy=yUK^0O zJ$==rPfjcB$kPK@W=D|t1GrJp2xTSNQN-p`l4o1+W6ROILvj@@GW6Qvr1DO>Tn$hq zPdO$-%|!-~@DtYtcMaasc&YQCK`Mh{9sM5 z;)sS-tezl<>Us2$?|8U#q(rK+=ZAzqS3AwcaOklU#a71l^f`kh>v0X3gQ6j`8)~>q zzb9H=WoR{+yXEa4#NGmOOp%gpUQNdyVR=m3McWcp6~k4mv*4EXmdE7TK;DQ|?TgL_ z>3s`D+g2&cSEkQ}J7*1jOp%^wJpJvP*@Np(%Modlj`_k?i@w>>i1dWNUk^=NHzB9@ zeQlRNNh6PcqZ^E+xilOq$(T0H_Z^C!eIjoxr*%@cm&jloTaM+18*f#+5SFK(e#Z(Y zIl9G~97lE7KJew|)Q$4QoR_?w6yX&tjF#N<4%Ez@Y}$dFHWGu&)$2MFM6MV&U+n1m37`|tywuRb)7~xC9J|qO+Ctw%c$WOayzY6U z_4KCT#;4C+DIus0)f znM*wmJ%0Dxt2=Xk^&EANwT!EBFFS*L{}v$Q-Ouj`S}sgK>3-$np_=rC&=rj!=Ck{{ zPiZG+@7I_CS_=~$QbO20%XyN*2X!a)6w%)US7w-F zyb#x^H+g%!;$iE386m@cRIhid?K4*L{kN|)$-3dlxc%~SBNKhxxQBxSF7BdNhi&wp ze5vM_9Z4$Ji4XXd?ca#av`+0e?Mby2J9Bs4?x65y*B_Mg-AWCuLVIrJvBC*X)T@HM z3#FNTu)xb7O5myYi%gC32(%P`?1Zy(#?4Dzlg)=7RSn(gKo3@)&bdUXd2tgKZ~#_Wjr-(#FKRT-M^n&t&n!e9S%earIOANB)f(AMLP8*nUufrq{}S zkz0`{S^VbyeE;kI4g-U9Rm}dc?-ZP_BX5{98qJTlhzvQGUc6)40voy)?9wH*g1+39 ze1%PP-)iyjav*Hruw^zjINa-RDOTR+o&S*hQw6&ZrS~sAR4btAW*=saH8?Kjy_0|9 zcdebSwM~L|ELoP+w>?*XR;hg5y)}GN%{}s=nV@Ogy6OF&aWS|kMS~B-`6qk!D!5$j zXKNjHSH9NNz4Gc|(83Lu$EiZ5EG;MFYEQMP!uQCgy31D@j#1xM7(8zGKgX1(Tr8Tt z`;q)SC-;5l8c8x1GDG(^scksYSpDvm1gB`MGxHul^|_a|bzVgpP4Zc;LG(&!EGeN> z;ozz9U$Cm&;FsxUX0PWKKRjeW8fLRr)Im|*Qnc@X{7?!WhXEZ_Jd6^Zov#F{(Ui&? zOsYW|H9W171^Qc)u0`Yk1ldGGA1^mYn^0!Ga-qFfSmL7W^!q^9G)#7LH70q4R1lf*YnWEKmhhb zWYf80fSMt3jYuBaN_0kgk1bHzSmms$2H;gz0nUo8ObROUW*VI+Li0h@RfvsZMnlM5 zZAoz45P-;_H9PG@jb#JulBvr)0Q%{>bJ&2R^Cp5U1l6cY^q4>rToEv(f>;qjmltU!@jT?_IGZ#IZ(FmeD;(no=NZX7^j zfa?kcDI`@em?h;(3j(hmq84Nk)zyMw4i6R>(je{OLG}d+n=rj|W{lzrs`fbgKmuft zz<;en*A|gDh~%DHJ2+$G2Xe}5K`77ylGE}QcMpMJ#t7l-jnD@*cYNDw1O{n~10k(9 znV7GX55z~=9;9o~FgS-OUN-W~AsQbCzStJbrx>X;douwp6;zF&r*wCNzkt*sUp(Nt z=(Kb)NW)Zt=?adWgl7=o85Fb~%9UVSRn-VWH}D!-8v#m+1`^25Q2-bcK_lNrbOL(( z%6qUNl&tkeB&v9zo8TBU4~gQ_XgqfcX=WZf+&}~v6?f)@WH$bw(M18ox$g zP!fpd@jyXZZYthX`o0fDKXT z9!%9o@zBjS5*;cYcyj$lhuU;#DCl*|R|F?N*#Ls~byUIuZ`9A%5kUcGQH6N7RpJRP zX1O=;J(Gvoa0FpP6dogJOO%6H+*ccFnCZ|13jI6~a&(goUJICa85|&jV*{hfcKQeq z>ac0N0tOw_P%0t_$${o}BxVnGREdWRB@MGUTrZ&dLQq7$YZ$4jxv-jmV{?2t9>Aan zH0VZSBB+ncM}bE$JwPM{Vo0(haJ{pXAs8iAd6N?O*wc9V2n3Fqr&12`#i*zgC4h2d zgF-tY`0NyWWj1;PR*u2W=qC_xFMxppJRSL+Qx`hmo#x+EFH8%pm6fnAea(O|0 zwhRIqIHIIJV?Lh8E)Z408agWhP?ib$8zuNSBQG0;0Fb#>zyiH8=}cii*-0+X*bduc z3oeU%qo{BMx&rv@0H~u9Yv3E5jkVzQrP5%XQSNR^Y*-@&{7zRD@D6V@_qk+x5cCiP z4jcS(1jii?iW!xvoFJealMCY7zECtrnE@C@43c3>CWB885`lq;2NGogt;VXu;EZ{w z+I-L+$iRY2Z*TENJPD{$>P$bL=pidKylV%pe2Ne_oW+A9Q;ecQ!Q~iScZm7WK$Aue zLK_rwepQ9Q9wb4zZ!&QOkc~r;1GJrZ=U@>U@VF2R21yc9T!HjnBO1~O8bL{H@Tn*A zX__qDMw})Fs;u1pdj2i=`{3{Gzx9okwo;lomwwAyB{nZ>`il-UyWE$<>P^J)gd9)p zUl!}#HP0(p>q@C_V-J42i$&?Zc`d%rCpE>YZ5txz!;SS}k;a1E@$Kjv>!FEfc2)_> zE&L1KBW?VArabSASF<^}WAC4y7iPSGPmwbTmCWs*XWs8WUAIzLVQ1rdP+Zz^UjY3S zE_lRiq~0qNS~2NvDO(TE(H+zcu52KOkPO;;gB1J?p2KChwUNFE9Mo z&`0aJXUBR~NKZY<`WnEgMeUUAe0Nv3TELwOY#?NuIK!ON*EZrI&Ejn~FIl#U;VXyE zSMSh#)WMi}ys+Rq@|UpUvX9CcIYuKcSY~cDU(KHtVBYKQOuHie_(w%HBqt znf*|=h&O5L&n~@T^$5cay!}>nsXTVfZ&3+~Mcyi+#wc)@@^P&_XKY3?-x5#l`UB8{!L-d-@1kFXA8yl} zh4Gz`Ejp%xy?QJ-?%8wnpE&qYMwmi^N_ElFCky(tFS?`Ji@7 zg>^tpU*fggfFPLx|4Y_e!+Jy>WEyTWiH)2a(sp;4+{xk?I63{GB;6>e(Va4lsM`P0 zM`FTZB`aNk3;s^&E?<(woU~6{(w;IM=o{!!u~}xFXa20=Sjke)tq5+siat7#cVIqa z!HjC?e|1#ccI(1ew>hrGvgRF;dF0frFq)+m=2a=z#*k6-fACasK}EvFR7eKQcdazItf7C6y(Q6L5-o9NayO&B z2QyUC|HOX|&r)gE*kfQ_I6fiX_ccCk>Pj~|%`vuY^?RHWb|jtEyw5o{wU2tJwS!eY zI(Qr^K)IO&XI22Pz6H_Q*O;WUBpg61=Cfesy>Ac;l#aK zGATOUz}IDG#hj26S%i$@rp{SDBAcw`s8H`<+Xg4Q%q9=_EB#z3h#ai~4rS2Ikt;s9 z`NH@d^r)%7Hj+NC8EBjSg5IW+B>j>npvvE{Q!^Yao1?Fb6aES*^0JqGa|iq@Up%tXF7 zG)0Ec${u&qC7u-bU|oFbBsNKXZ|~N*H|DmJXsTuRx!&S!2IObXJ1LW{>QOV(Ii)UZ zVWpO?d)1xOOuhcy`tR^2#J#v`(pzXo4eUbgee#N>_BoddSX&Tr46Q+L9D^?h->;VN zw_4*ZB|Xn^GYx#FyMwjcD(H^O&EaQTMeC`lTe#?3H+#9R5WFOmhYn6R+2i;u`s82Q zF6)I8OrKO7-t}3!b7j%b(;TmwA*MG3B776GSH58S_WT9Y#fb}#jD{3PV_!k)^6F02 zTogQV^-x(eQ{&wsrK0IOV#n7a&YZO6KTlT0$;C>1!{2(?ba~e^dTx_srYkO`o((fD zo-2yJ@}H-HPY^4hwx#2>*oktk*)d|4IL``~{7i`<*#0UFZ6q9Q-p- z)ZazQ3hLb3hSB^b58Vm*96dF&6uXGHGc-J<#C=s5>~B=?M_=LBFo%@Tt zv({~nd16?zq#Z6BZknDi@9=X}#(~M1+TU_#VaQHOqr4~8g!P93-KXd=#TiaD-ydRh z>|_rnKgGrJ$m9zJqg1_d65l?2tUmsVbz`rqxX9TEIg_xVeXfC$pWnb>CL%JMsw6*( z+L$99PU|+r?f1PDV}>D?7A8SgpzE_j*zw{AHmGG-`n?|Jiv( z-6K%D5ED5bT^I(3a;f*!>dMM|v?=M%Mk$R>BeAPjOb%YCVpq8Qa6C;%|B}J?J+UQS zbPkjk)jo1(>0jHVW6(QyOs0SAdiKNh0(;Ylgb8+u$NS{TOEp5_>w`$!-zI;qrOm%i zZ?k^#-l$z*jQDZuDQh{(`rx1t&)C|)PSp^!4|X-j z=6Qtq8RbpZ)<#Z?e+?J&EN@cCd2aWaYU!?_-jifvXEH?Jef6V9#ug7KGTU4rlv$`e zYVv$rK_f*#7%FrncJ8|VN-5m2F3tV+x62Z<)f6YAO7rT+655U;Yo#GS?|!v_L>zZm z6B2Pga=LtC`_{t7zxD9CfztrgzJM z+dnlHfb2Y~CaI2D{u6C)>5wlj7JYr!u{iM^KcntTG)J+YL!x#uerv`@d&*tz%=SY! z+`gf;#@a5zW_Z{;LY4+A-1~;GEqLyltX1BFkM$^L%PmaOC0|o4#1>d4;ViDAv~X z2&~f4@4Y+2rx2&Lhp^$lb&hYvaoeK^Ett&&qz$c-}dsZ05C& z@56kmK4xNf=HKQZC`^$q>t7~*CuCb4#O~j5e~06D1S^*nhmGH7?H9ET_FvHuh0X1y zC&?%`{PDVDEiaGQ582I1Nv@fFSj;|c9b8meDJQ$;J7oW}X*u_9v~j_~BbY4tt+%yC zrEv@T#3}DHA`dmZb0i4AOtZA+xIqtJ>pu%u>TzpD#^?gk6Z82)fLGB{e_MOe%moNJ zWK9Wo^@csZ75T@77yCiOChVuJ%ay!h)@E&--{oc-Vz|2- z84C()`Nkk1Adxu)q^t2pm3vPWQ0;)(pJ;g313a97!Mmzdh!1-s$aOdx4_#hZ)d=wr};j;SW+rBH`L2`B`Z)(8yqY6K8YlSBWF zULgaO+U32&pnjw8xq>0SyF;<^ejJU(cA zOKDMl1(D?nvy~O#B=S@Ns;3G8+ZCX;Umx_O<-_`zRAUlHA6=oWR9FQ}TNW6uz%fW( z#6})Uzys zOAj#J0V-V3U}r=~-$-WR^zs5FG6PS5a~Qzh1?doTG`&A%lmdnv8eC~y;HuI@b2(xA;^MwbPj_g z1XeINq=@Xx332>l&_HwXiM4d!b}d) z*G>w=o(=IuU@8OM0un+z=o!-%1>w&|a3lz^V*!W}r4O?9pdOG&bF@KFPa3XIl@E*% zpP|utJb9(AU?S1n{{qa~!Er zYzhEWHzqqkREO1!}+N*|lkU?Lk}zws48 zC;?05a)--I?3Czy+dy+|DOg|>sgLCA>kF*jPL*1KtAc$BVRxanl{7M$2K04r&_!ri zgh6xop;RH0+ed?=Ft%l&D^UR69X8UWmLKX3EKw%2Gej^0=ulxltn*7|KGcZBvj&}? zML29{L#QzzQoTvTGyt1AVZiqafb%2~`6z8DFlA9FK0%3r(39v4MFRE}19DLaYU4W( zv@dC{kdGrb+tFE70Ko&0F=(*UM}{k=_kakJ!v$om>|iRa$FNkk)5MWPoB0|A2t;jV zrRv%`ASRm-?>Z_~HD&a0hdF4DwxR^Mf^rRuTmTQJ4*_ohxkibD;DgctZ+B)-wM#1K z3gvi%r42nr#h}`9pumFwkP6D{z}(0vfs+qU7gEQ7`d@jkI zhB&&cJ0ZVtuaNCfF<)4d_HUDMH^%u%q^!(VT&Bo3E%ES1U6r6>V|MTb{|@udDX-nD z?#ub#Ze2i4ySc@dpD55*vl90{vMr_5^p(QyN!>&17Y`VF8=UmMVmv?*&|s_8x&e|^wq4>?vOl;G0tFt@u#aYKsWSyQ4cNO{Y7HV?;LjHKHnM#z^ zc-UOaCBCc0>(1RwPunzqJ^$_4ma_RRsma-cU-Sb~-B0-bzM1?*Vk|b7 z;q7y6B*}*QyQ9`Y`G?;ztWkSz=I05WwJjk+{4e?Wt1C+hB+;ka3nW{e7P{=;ZgF)f zVWRklpA~J_y;MnOa!L1V@6FeCKiZLTHo`$Z=JA)7qF4H2*ei*-_)J(}C|=%PIM>i= zZJ#mlmwZg*diIWH$FtW(ALsnG;U|0;K71g)Vi!Rkx$V^>Q{#1Lch7XLwCEcr5uema z@tRJR=!fj!Ia8$Nf>ro3pYGK6QsSCd5%tBk%m|CMQhu+Z$w7HkPUpSj z!(nw==P~LtF>gf=5*lLz=XbE<4yG-EZ}pT_V!!ZmmQ4{@Zo=BFn$HFXjNQF_+&--n zq_g*+Qp)|0Z&c)7`PNw${?zn&@5&NhI;gx$@ZZHhB?Kmp!Ffos^d1rZHnM8F^qa z)V5yV&7~%UtYfX5c`ZUvThVZi>~rC6)QtfnEv=R1Z;yYXe5WQ5#Xr18hm1iLTXjYH z>g!AABR}U||7GnNeb;9!j4_i|0E*iN!Lp8wl^f!MI~bb`n1mNcguIvCw}mPSwre}TsEkX z9$bx%=uC2ccU|amLv;JverwGS4+}o{9dr&Ad9?G_=R^;p}2awmZqkx?<*Gm!Xl+rf1b{;<*+*}4-xnmK~XiG~hT9%sOk$h7g^`im!CdZpXK?T37R2vPn1f z3Jq~hr5ciM>+efiI`%6ZT&qNJLPo#ZpR6wHIUQf0M#lG1r1|X4zU{>#-I+3y`@DL~ zX5lAaSO>4{2yue?8q379SA!QbP8LU^km@GGO5KlQ_LgOjT;{e9Usu0TH4=mUhI<~Z zQ!KC1-LUsu)a^3pi;0Hg*!b&|#v6Or(dS@2*0rDRS)cCPaTjvI^3Rjyqm~M8t_QYj zBKTJ9pEC(RDmml%Rb9sx68ho+P$WzCu2P^xS|R2&wU48LyoX;zsMK$ z21btfR%c-DrOh1kJM4JSPy3_SMGN^SHA9nad||~O+7RNNL?HA^$j!pRwSB%&%GF~7 zL+IgG9ybC$49BD!@N%9Gho0VVt1~Qb5?Lf~*Sy1d4)$ErW1TX8LGJM8@=rwkx4Rjq zyl!R3K;f7JgdGz_LMumfZX)Jt!(eNDWdZ( z6=}aE7kDV;<(nlLHm4?PzaO>8B5SrO6&edfi}Ly<#BRw2+&HXNw|gO^FX3p7+E}8k z`;F4=*_mp)HJ?VRL?D0UVfK+{HyPI#{5nd1XS3yV`th-$=Px0SW~kQ}FPcO1V7R?4 z@B+{CN7DE|Etjlzyxsd~#F?yik8h>U{E6rftwYUB(LO!JxK{J#s-6sQ4^IqsiLfre zbs_Q2$M%zd%1^GQR@9z6JYW)K-I(A{_=Zgqf196wLH_l#<8n94LQ+Mxgb~6>tyRdv zx(^>>3c`QP+-utn`A3(2Tuu4K`nb}55UrYC+PmtMlqI|0b|T$jzHGq9&GP*Xe{wdb zGE4S#P1B*g45+z@X)1aBL)!9cUmxoh?0L`>;Up1Raedot{LDMI8vB2d%G1!ydquZD z+u^$Ge{k;)c)VSzxSwnU8o% z#l^|~C~Qohnh#t2acM&MDWB*+`!2lZP*a-N^N-(~LQ6^rHKt)pWcuk@K~+UF>2Z+B zlZWSJwPT!W!jy0SVkMgfJ85=?@;=EIF{i(8<5a)k+@5uuyBvg8P^4xOzKhLZ&Aw&H zsDzqdeiV;j!^EZcjYnIT$9{a9vNz6nYsypg#@jQQz5#czuD0lN76t0cdHhjzkE<8+ zwm!M@8CyZV`b=w4%B^XpE9CN%ve?JX84eTN=@#9R-v>WrNHxOzu?lz&%cTxAr=Ln$ zR`V|>x=$V#tN;6@mrHxO9x*@Gwg7Fg8-C(3P5luVv0 z41R^0v{yTjd$uyH?CoOI<;T36?pu70=XiTQt-m?)%?xrcDgK(?khaI$)>(teMLWXz z?%OX#Pfj=yJhK04!TfhkG!^&k|7(%97l$8ao6ekl)3fT4U;VzAf^#^R|MQ*|!ROz7 z(1*La(mOcj{G#&wV^=FT{beIE+svZ+rq^!QugYQg%$M;?J*n0@wDh>-j@Ztqy$SW$ z^y9)_eJ8hV3DKC|_3~S|Q#6et=hx%s+^;{9vh!2Y@p}GN#t?^BLQfa+zT8rSeQWfQ zeRUMw(BB{zT|M-iUnZTGe# zdO@eg-zPPC=hw|H2CqiQk2U$mAJex@e{=9{C4B4YZ{hY4H&jC+6JJS-m#=aysFCyn zx3^^)+xfBVq`Se~!mEZ?gN~wH?d~K+{!$~HWaqe4`VXftG_&>gEsq;HTJ~vlCve_& z_sa6!&T5=>4N_P+8e<>#x0!GI-MZna=-CHWV!wxc5*r8ORuHc4y96l(=kt7+4c2M% zpi;@o`?8wOg4I)%0DGDtxjy+d)Rz(w5!Sz~yElL?C(^C^?nd37#PN7SrK-Tg{AEk& z($Sa(eM^TH#~sZ9Lwj)dmz>Yt6C}Y8ekRA9B!L`@=F>1*aUQ^BI*iRtmgO2p`tY7(N>>-e7Vomowkdn4@lamLKR zXZy%fY}VG-sI0ND1C?F-&bOuS+9O}AAS{1&#{rwXU5OT1YJMi2>G(U2vdv*m+s1+x zad7(L>-Ok^*e9^oIQ-NT_#GL6-=PFA!Q&f2R{Lh$pX{{3{^ z?p_smHa`U0`dR*SK=6mF*CLZ|Qgq_%)na~5ItPYO7h*4EN8#9sEHixIb7!-cBfoY{&tfaq_af!{nINnc+HmP zYngRP3$dy8A!jfM?LsDRIQU6pRRkf)4QCYOpPRvv*PifKxf(}gYdQLyZtN+&uNU%Q z(Jf^z>*VaqqZSYPkYS|wH_s`*4B^eS*Aj1W+pBWK!tWKP7lnHj7!v#CXh%L_BN$5s z7trkuXFt@Bm**W$?qB(qviZ28f>BqCp7Y?r3W|x zpOqiff(8+Qy6+=7At z$w>|zNK}*&fcJor%RquYy5>xMR|?1zy~%zXM+`JIj2;AxJrF>Be=h(HJ+!4dslyCl zBXStF9MVR$8>x@hlMg0B3md^wBnfO}ZnXJ>^$J9C;E84@1wJruLo#)w$c@#{_@LHa zS(I8Ptp|ap^w4mwCZI-ZM%X=V?9G;=hmpCUq4;hpxWUGtK&hXvwjy#PEd|AJ7*Z41H3%98YpQVI(F#Gc znfEg=gUVn~Xx4x=tOY8ntKt7uEkpO+-Rq@J9w3Rp*jJ_PRFH8@Dr_g$;5$^KcAx&U4APWLk4r;*sEH4?riw!2_ zRbVMqhyez!6Pj!%Q6>$l(mW6hB_;*!;jNVz8jS|18g!-}>|NgV)wX3YfZD87#dkG| z0{kV3VnT5>_69X=jUZ&Bgi?*ci^QFGD6~rs1f_E*ghaQH8`1>!Jvi>-Jw_xU$rW^= z0Sz0xxZd97AR7!vl1M}rv8ozW>Jb5ig$C~9#=nAugXvWy#{gL!QCr@~xC+d72!e{j z(uIAY)N%oj6iVZ+La=_%hy80Mg1{1io+FIarfpvyddCR4AgJ(es zsVV}_sB7{Z*ph(rP{3VhOR(ifCBSLMgL7Ebf~GQQZGuvB5yBnpE2Bt3d_2Bd$Oap( zvb_M^gO~S#XmuldB7>D!G(cT_8^PO%2Zsv>Lh=UsN~EU%UKX5qLKXl9Wi0< z;E7|0dB95Em;i|)b9^1;IFxuI*jMFL)i&t@YZMP0XCuqTB{B@7h>=SNLm4pe5seBXHz9#TZCu_*69&c?@^GLD z&#;ys1a??~EziK9^pru%9E-zHhO&VD92ORKR?V9~|3b%)$H$s!%8tnqjJ*G6=Mb z;5>|NP#*z1WaL%sO$5}YLt(xY-6+ZsXv}~m2H(dhf@~33)FAc)XJHjs27$9t9(sBT zpv2S26-?knQNah_8yNUtP?<(RgSJst1_NiyR?P{(X(fi-N^}*Hh3nb$?j* ztXtUXq}Ch6Q~DCrl&8aJ8K&2Sg)}qJ$OR2N^6(7tUdyhn9^-XYwR&zK6P zhS7@-VFQ(?FWNO8)=-8o{s}k1#Rf~v);S+IdIaZg{b{x!Io17WjI`&iCm)M0z}km# zypY-<=Zv@&RJNl(U_yuPtApzrFJMy0mO69?qptJMk2vT9*GXzcANl ziVlm%jeoifk3*zHO3AX0=}8|B=FQyONA@dw6(8JiaL=so^9zZ4-`%lqy_mv5C!K2X zi4<+qXvBB>wD{MaJY+pWh=LxC2}ORe-S3m~X@>wy9(}IsJghh7harX?yWi(&P_)f9 ziiNa37U3p0nWBah>Tpt9KfN}Q)UNa!T(K)}^(>cFYz*C;N& zUdO%;>be(m61ipm$&KDh*emj4Xvu@*HNPJx7L1GPkZo;hbj6Cw?Aa8VqwlAFX^pKe zAD-#eK9L6P`lfcUda2S2imh(?``{nhg2}czS5$5H{tDDq*I#=vGo)^>mQ#{R{ABwj zZ%35d=Ju;_SL-vM0SKJ+k3MoIx@vb?$TS;wc(po`PP6yck4+bz8v9cCG<*GtJ;NpV z-_Nb}9i=7xU4Ae6mqR1`a+ky~^HkIw^m4`~>t4qaOQ)HdT&rbc>BisH?)te&N}&sE zn_6|fWA~npSDgXFg-rcY&Wg1%f z*w+$rhxMyh!W{EwTCpkfC54iQ;fUZ4$2;zti(~AoVKl2vM~`MgZ+D$i|C7}E8)~!1 zQ~!xs9#znVv17^E2<{&E8db4f?F}nM2Dj|rseJC=`C`_YGVd&L5B*mk|CrbIu%&KXjP*Sp=Ehkhz# zo?_p?yWcSSVtdKfe70fhi=X6l>4CZ_jxd_z=Pr2Ke8LBLQ08Uc-qT5NN&ClMF!Tvw zd*FpHpJCrlK@?p4uAhqB=8a8$vR`q+t*>USrzJR}iKSZUQky$;r0mqbi;80J+t#mt z^MCW`tKIOAhM$=s{p(Ze4fTXPYd;As9k&K){j1hxQ1g$Y_uNNju>XW-)_F_bD0kQ$ zI6H6QUFgctPst2eNQMKi@_7@VLktZ6=^ce5sW#8Yu&%v&KU=!>w$!**+u`~f;!BL{ zhq>1{`=WQt%YD87$@>+iyN1=~?>Er+GLvN=l zG|WK{wW}NYdiABUcuY(rVriJSkFzr0DzAqYgk5~bgbmQv9XFliQqbK-p)c)#bVkel%WsWDtE- ze>FMp*7>s&pL)f8iPa`P`ngPBMAo5v-wZuy#MjNFzNKBepT3*?TbkLsU;nXmp4wjd zWb0k_oyy6meA}A&*bk)mNW)I=dOXzLUzKoPdi3~3K>pIgRvo4L zJA;$+P=PzBhY0Gy3+(YmImcG2uVn~V1Yubo}VT0nC>G46O$hlnOd zRPWL?QR@?gu>bhT`0S@cDuxF3Z|DfSJ6e8c5{g0?(|4p~L$db2im!HtT-Iy*nki?M zs-ePGu>9$5G5ow}1v0j$3l{aJT1K=4qG zYhY^}K_;1$6=@epze7x37yh|;mibuLH>qUc+)cZ%cCDpkXI3F3D2RMo zW`+>(0&y%l-G3+CQuY1OU!rv%DeNHf-HCZcx80An#p-?L#ru;#G#5mh`Sm2&+#%FA z#pGO`acH96eo}CE3CyeCJFdpGiHNmO8XCc?wHDmYGI*Oc?G+(SxSEOk- z;p2w-tPkIKBMQ)ru-R^d@gSb-nZ-?B{eR2OxfjpmosNCqoAFqN;;zP0R6Kv7=TWfQ zp}Vv5?X-UyG(tTuuOic z>K(s_wW`<7f4t@CzgzRg9o(%&_o7qh9g@+%V=L!mhfULOiUkqEzuECbisny#O{6U? z6rXUd486*J6Uj*#5P?2i@BT!6>2YQ;{iy92#k(`A|9s>gUbH~|%W9amT;=TQ6X@<^ zFW+U=&a8S}WB)WW^Icu6ed%uW z?C9!Kr$1z~e&-O4_7*!VXwYGq zBlFnIiWk^5YjLC@`peqxoi5hiSaM9=qxn8`&&dRjG<#I3*VxgR{zbQM8n5VwI)b#P z{zuZ8z%%{$fBgFu<*G1ur)!wal}e?AksXfBjIE{2R^})qg^q7R<|t;Hv%@k>goPaG zkaJQII?y31rBihCfBXF(-|yr5=;85g@!4meN1xZ{`FcGQaiazdw^*w#6LY6}b6Ny2wr)1-XLW&0wvyVYGb zj{mG&eCwZdLeDLwCATycw--7ptZiAgWO&K;N$b68r>rk64|rCfHgm|?XuGa*0u|UwG=jwZ%4wPN$gjA+Jx)cxg4C z7Y17A(zT~Hu&Nnl2=0r2$%w;9*~Yb9>f_R-rknTRr;4%;9{A(6ztu{Id-9=Z<`D78 zf5jP;f~?cXClNs&OaL1_)|rWUb?tV6Ets7nes$_44^Sd|8L6Pt>M=EW)R3 zv?5`N)>AuU=a(5S6ooY172Bwg9XMp#@3$R_JI9*%D0V-^mme#x4Rcz3Uq3G`;$yVY zWFMs>*JcIoXRr>HzcKg z`}>{-l{{FA~v{hzy@*>yVGxfNCg*m0ZCM(cOgG7mmw;J5F;i)8N!&?^)i zyY%HQ(@eGR^P=&K;|4w%jft+S_ggGIIvf(GyNuFmcOvaTIoU#^EKhq)#orqt{kdvQ zJAHj_UC{`V`}#CW+_-8y>`me6$kh++A1%(H3w~v9-LcjF*0t3GnBZHdR5)J*551y; zpM^{33%hZzypx)@cz5izS(T|yYrN2T(%?u{;-#d2Zn~qwoD2T7nzWv=-+mO#^4{NA z_E`1ACA&<>Gl3_QlGf&Bp7<5#_UWlLL;I!0%9mDeGf8Un&~n_<^6V5xB%DO$C)sSHGJ*qN&50I#`wIyBBIq?C+UxtGF?}#N_FA3s;6x z%ELvc=g!Dy#jV{wkgK0jK581}ptIrWmoTGK3a>6%Vh>?D7ipBEBwZ)byKY%uHcUQT zD?KU;zIoR}Cn36cSM*S`64!4rDp5IgrZqtWv6w0!bM_4FB7<7}S;XJvc=c)yZG82l z;dGRyYh;dL%&cF8!ub9gb@dBK&zhndH$92{Q}goo#VSz_1|^TTO|nLhK3Z2SThjLZ zYvak?@|t?4?uLgFyNYz)HjN!T>yEu1DNHb|OO<OUW$mI4hz@94T zmWtL`YtqRhZg8&}0Y(cvCp~-N-sYCoL;^D7 zYnJC<=mGLpF|VIhho|9K?I67rF<*v|@Q|gojSbY1$2d^hM4@4)4n=A7B~lGw(jEXU zHd6%c#X2sJmFuA-THwXR=B^|%?B;{Y6M#cH28nI`;)`Y=beF=S9a|#dWyEE<;J}cw%5rOw*%lVK)E*qKhbx-ExDv4akk+N8xkBZ52pqHoG=mB$ zlu37*00;gGTZpJ^kT9QI<#xjFE|@ zr7sYRhBg3Dv5PC*Z`&DJAQDuuJzRk~F46{QvD(~h9h0No^nPVjh-I+zhPgnrSAN>2BsJEDwVO zdlwy(HW{6yhX&0C84_9WIzUWy8=ANDELI;|U@SI)*b6$*)`e=VHAf71J(fs_Wy>0B z>(F9oDZ&sTth#B7#psGU*!u@4k_Da%s2TDggiKI1PdH+b#2f+(1}>zMeObN8&cobR zfF?o!=G-qsS}DR}+=399#$-N7mIDs4t-mL6VScU+@};s|H!_TU8Sgo(B(kHif#tHl zj6nJ40mBDP&MU|qpcS=pv{SlZGO{lOhVCF_=1B)&VoxNrgkVIt5ENtEAYT!3v#cd% z4MeF(tWDvUTVjB|>$4qzLbt16VNeo7io&1>pdgju8IlTY}kjkU8iI%ac+NoPs3gbG>4)K~j4TbYXFceZh4mPzxUu>q^Lc zDI}$3=~Q1DClrn&22K-KF>i1|v4Uhs$o;ZxZ83`6UQvRez$9X8M6R^Ij}s!J;h2Dk z)v;kvlF1?2OZ3zeY>}`rM^|hIe?%$)m)h13e_E@w0_xlx0tN>=lhzeTI=%7kSa{sQ;0}p(aG+upqVA+s3*e5ixx$T)NPC! zkV0A}Av1fNZ8$a@ZvZ5jAgyqQkv(jGe-D@6t!w)4BBjMDJr?Ne#EM2L5q9#Rs7x&f zkpodE6r#v{3CWJ(sm^1w`gsFVOFCR~Y>A$h#7cZN2!YL6-8ND1LSmjQ&{j&%isCIe zFv<~zY&N!!l`9ni1L}qZND~vXFj9$tY^`;yI$5a=O3D!VyWv5joNZz0V}S(xE|p*F zW`Q8+nA=H*=Ns&;G2au1qu)esk*w?>6&hp_`Z0I*bTnvv#Tr|M-v#^H= zhZn+aIE{EW2`sdQRO8#KaE3(M!si2_l5|{ClYozRASx=&MLjx83ydfl8(V#g+3Ilya3CEYq3(hyv zY@0CB1_L>^-Px8%FP2bhB@is8(y)2H+9H7o*opZIWOP|RT;?E6#n6o8`(P_=-I;}0 zP*D;8kM%!Q{|M$^%roYX))upq^>pg~m?0CyQ`oh|QP){uE4i`f#nBZyP2#}A#MfpB z6~$8bYxBvcdwgGi3z;bl(sf^w=zUGQu1Jvh&UZO?+tsLqG1V&spEGZNlC}@1r~6V0 z?6Wcd-Jh-rP+)bTS?y=I1gm7ZaRp7GwMRO0e!TV0jBV_p8yEcsO^s$m2b41v_mBQv zHshu%cl)eelarGnPJ%ucI?{{qHf|^${e#MVot0NEj9Gu+l}%Z((-i3<+V7TovHJ|y z&N##Ud5M{kjbcXch+de%&9wOhrTi>qQ_n*4b!KbXvf`)sq~6KkgxTUUsGjvcE`SWL%f zICdEgI?u7#1I4fFo)xJ(C+U8F<&1kEesV;6eHHPu&;IeZIm4^w9*rb;sov$KW%lI%au(5)C3}B{ZNhc8pfcsv z5zc$V*>yeHwrU;nv~8#M)D;-dN`EP@@uLuTF@IWriEM_3aO|cl_J#Dxu1!V=^kL>^8?*B~aoXwJc}uobCVMXl^Ie_*$=JnLk3+HSuzGkxQ9W$*aO=Z;B!O3UOm7jP2qGepGARU_M z9~n$BCw-q!OaDO(d>XFp^`;16iWZWA2UXxs1PIDG$La__NdN#w~d z315%7qcJp|`9a)`9~ea*>+Wvn^;_{{zkbgyHN*#<`zP(2?W~Z#v{v*-@1U}=BIfSf zoZINd={Me`ChbSq|6YBC*8gsUN~n>ygHfQ$*}>|oH-;5dHSTPjEB;DQF4{MdV{xSMr);`rOXzWU8 z+_^i;bb^XE^pJq4j@+UO3QZVXZ`|HY4{r6pjz6Z0ziM>s6jQ zEOwT;UGoY(Fzq`tVovWZJDEW(`Maw3&=sqL;78ND(D*+I#v{*)Kjxu76&yzrOa+|LB?E>B1T@b)%$w6{ zPIr{23%rVB4vE&>+zO?frPRVq#A+dqLCB)3Z?JZ%hY}6f>JgXNxXml8ZLdtE_oateG=O; zU$=}uNj4{2;ioT^mlHxQ#=`dX$&M6t(;v>NT#DRkrk1nL{Lzghovgp;DVYW5gqeOl zr%J7I@T(DFVE29B!l`o}I#V9&pA~S{AyaXa_gZP64R323r-toRJr*U#T@4+=Ea%-H zbNwCq?~iVt^ZIB5VrBW9;tQ9R)0^vedz^2yxf^Z2c6&|842cz2w6@=>%#>T2Vb3eSgA1k(Aa`u;PVTcWq61$5C9 z6CFNr<{aMU>((Y$s_FB|tS?^bx-(IMomVmCY@+U!eA>Bht@T7jAqV;_XJviVx+Zt-~bQSOsHH|sRRoa*AQIE6nWG@OqgWV6vL zI+N71Gw0iIkM4Y&nmzOS>y@g_>n`oHel;2(of)o@-ONx9KKJzU>)HFhgGLh%ge?lq zK_d;*Q4{Nsw_{kJG`oV--2IwO*+XAziZm73B{PcMu~R9JsC^G>;~Tqn#P!agwkCOP zxY+rXKBE0s_v9OnqS|lM?(W&!@`}$6IZAPYoDSX@CEALq?A#3#gdzjpy?=34Ye$J_V?-+2} z)G*Ee(rH6^FLaMqL2n4NYWT##I-k-0uVaNatpOh{gci2nwn-{Yo8MECpqpqDQn2@J zZB~@Nn(-I8kt?S6|LnP$n51g;r$zy_MH3tDnfSXovMZa2E;+WbO4CpCz~s*xtB(tM z%Q(*UKH-Wv7w6^5L%f*!%{%5okIFEr0nNU(mPJyY^A$qS-&KFFNf{Pi={G;keDnD0 z>r2-yuc@7QyemHXAtu4|#clEjy~8zWr}} z!W5`jKijzO4aZ`c9yU3?;j~h>5A~(T^pzJ0#!t5PyKUCK(|C?Wsws7&&<`|RNF_IKoRMq>bP zr8KdB=~~C&)2XR}XF23!6=yGQ+s@san(f$1Sk+^gsA9aw`Oa3$(}!0`JbEv5=4)r4 zn5f?@(SIrb`by2;-jzoaJOuPx;ww0TuJM1|@YQ(3wb$NvrB7?3-=3+=&OCH2i5;IyQSUy(-^OCF4K17>ebKAyhSlqw1h9c%bTiu^kIpX z=j95O63@im*_T_N8zF~Q`+vS>+^=upbnH}ZuTQ|py|nLt!%*(Ix33>~u%f2)ZswM% zrbf5ZC3h@ruU=ZpB!10!<8#35`Nxn{6G<0yyN;P#RoN;WMyAwkm-4aC&}8n-H)~OB z&C~dYhWk%y9PbqcYDAS?nyXU3+y0?gB|+$LPaxH{xya(~OxS-U;aPxly>tD?dG`;n z524l-d~#j8yz`+1sX2EB97t$Arfv}4sJFYB>PilZ z=|IFe_mMO>+<)@^!{$jvCCU6lrb@1?F`KRy6tDStd);+L_XY#QrQ+(8wyRZCo4ie6|hQ|Q89ic|VYwrZNc8@4k&>xh%{>d8%;oMX%#9i5|Y z+(B%4BwQCxoZX1NAmrF=h;NS9*kv$WSLxLiW@8m2`C1 z!C7wo(K7cK{*)2wPJd;Xj`eRtaJ%3c-TlKPp|R}I~+b>mz8ep!Fr z^XPTfh@bJAUt#`fr{p~qYxl_|Rr&QMek>0uZ8hmCw0(&EprEYMRTP$9>3ZXm8Rh;W zVfl}5rIoZCQJqqA zS#>0L?TqI6!P)+}U8ib2+}gTFRay_Jekbm@R_YhkxFP%ZkBhgqBcBN4cYm1jH%tga z?=;@})wITKo!Dw6O4kP%)wqn@%&qr!;i$%~C923K)9bOzlFP05J=~e;c zr!qc`=DV;22d-0RHX7^z)?V@tlFbuT&2A+p==x}j@fBGKFVeArL{lvbEd<`kxV=J3 z46wwL+grhrp)DHIU6{@XP78#O6FFegr6T!tfqGE0eUVP5r*x&m?X(-0+YVj7F{T7h zBeC%UG|aP!ST(A22-OCK5^^?(6M#4K<%&VBOYXrafnarjm~~PQgdkWM33#4XTepSv zD+m#((2z`PoiMjfCqUu^2Sv=6_~K#v>PQ}{jhUMZ&<{Is6Sh0Gtt%b&x0D0*ATovZ zFk6f@=VQLYEh}+V}?m z0!b;8_6yR|KpNr?EEoV<7>FRn0wKR5C6EXr-fmt$R8WNiA)XWhBVI5gRtxVlEV|J0}Fg%rs@e1E*gO=)w!%5(3{yH)p=0&EH5uhcW(`b@DI)@QGXBQ ziq{z)W_8-|MjnBm9Z#eIT-7gR04L^V&US@&I17ZeGU(qHg9Ek3uC|d4eOn1c8xnl0 zt&7|dH?3507QA{|0>@?{(t$xUiX=Flj-->ILuyIpiU0-_jlHxJQfTC?BzOYSYD+9p z?1mLPA+ZvJ7Y&-g|1(4p0=386lY!7Oan?gpYHNaFGp(B}3)J(^L%~WKdf^|p#2!3F zh6C1aKwt>p5=Fu`gHK{aKEVPEFUri|I_B6|zpUFjk4S_!hVEjc)OrMLP&Hscipj3{^%?I|7O3AH|Qu6#W@WL$E$BYEz&p!`J z^g7zLCD0Z{r6Ch+Xiz*xiQ8oDJvs=m!PM9i4U)zN0nLCkkeCatK+a>EE04YEH@>A3Jg9%{7eFCDgd$VV48>$u;I-VQpB(h z345HNE`h{akHjXyWSWux$9B1LRN zfdtRUMB)!MW_Kyu1T98W$^0~ut7O6LAgu6#ppR9YEtS*3-4+ht5^&fDa3tja-S}dUk%r&NDiJt7lPda!oc!upj3(Az`($CsS@CF z@O^_)hTRHSAv|s1b!XW?W~R)Pkwp<1@Yq4fh0bw~Hp2s+ZrB9FYM_i!TSK$4%QXNF z7UJeX434_+BXDdfK$l*|n6t$esQ<54FYtRvJU<(ulh_3&6tJ#%Xwz^gvJm5)4_6CN zW(X}nPOPt<1=k!5(h3&%AOttNt%uN(U;(j(K0lcXKRF$V!21`9;b{r1aT>Q33qZ+B zOlgw_rwbYIGYHG-a2)8a_u0`%Z;lT(IJ_6Pma6MhVb0PFoTJAo-a&B_2bgpvlS?#64qbob9a{r*# z(DcpJZI1<|&LN@v`Ca!}c~@Il(yOWOMjuDSZZ_?#f4v8enB?)fIJvuTm)no&8^ zE$MQ%QZA1trjgS(?CuWvW*B(v?bq%9CA?c|AddQo;|3`Os(F`BClD^L%((Hct^Y^w z+q(L(&({Om+W3aH`G;aS30Z#}O1*EtStQLyEtgI4NLNG=i12Oq?hjt6xTbB^hP{j# z8QB}*!Z|HSz1j#5nM#+E*WY@fk=nVRsDI5=UnSkXO8NfpJ(YpYPpKO#8-DSb?qX|w z|H?$`X71W(UgEQ@8tKEC*4SGqSnff*95y2trMv&ATS(!?IHQrC^3L_xXf2?Z+4W?a_3Wn+rw0DZ?UT&gETY*NpM9ZmFEVc%1ztI?v7@;y?(o;g2I{=rh<-1Y z25oxTs*6y^G}olI8`z)TSZnfUB9~lP{y2N|-$TatRJ95k|AbI?Br*_iW25bX zU&mJ7dxThY>ig$Z*GjG&+9$R-R}rVUdac@(-m-ymx7`Pg7TX4**1L7xWBR=G1cCXO=EPP8&|_X>=Lu_wP)QOPa5< zY73~!%Cz%!dF5fnNG~qjfqLUKwDhXQ1;abn(p9|Yl~X#fC7;TS*EN?5%JBi;O`mE^ zJ^J?SM!;MOqblOA1Fq(PJ;PpMx!91EdBN2?KeJqgdwrAFPor0w1^0uF{G8GSbU z{M5@ai~w88d-@Gd z^~V0aYTwp}?8i6#Iv(EVz^$XwhwA*?mw_w%mT zF3Q}MsYjg#xtN542Wo)+FfYV@5z`+(jjiGMsb2h?zwL4iwY4R$j(M28fl454wtXM% zH%3ZLczFGp|7$0bYf0pJjiN2*JU?0g9C}@UUwQ1b`}`KCKS4I@X>8GK=xXcF=Qi63 zs}9_n5tQ37?6CrpD4}aYy6LP(Oi+ABiupmu)bzX7>lrnje)8(X-S4Xm4}4i=A;igL z1qHcwAnP4FSba7UpHRP%mE=aFd!^y)9TA1zx-aY&7ItJe~M^om@9G(TuN<=wz z)0+&^8}hEDE=e94IDwtCy7(&1`{`-Ii=2f%D?n@N3T{n%t%apgQYmCJ|Ru6MQ zV;wsB{oU;5XHFXi&-SnPmV_Y%#XqJz9d5Zb9K@WCJ{n6;lV5T*GJt>Lp}~Q?9iz;B zbL#D*kKzW+j|3juC%dGWxoOVi;jPylGmTm;=TsaAM_R1&*1-c?pBw4vH;y9EhZa3O zaO76U`Xo*&rDhxDnRsaJ{*f;6(;@$iqX|ZyEBcgB5*^8x);uBC=ANu}@4DYB`dW8- z)@xdIV%J!G`*}IFbAw|1nSp_io^io-tM;}LCU(BV96Yv;-Qir754Q9cPk( z#to(#r^O)jx=mrUMq@1V%DJAU=QKGEB~6dq4$mvWw@EFS7Z9`=Y`H+<=N zzUZ?33lBTxBI1XstV5--B^_F}ADXWG@|xai?lS%zJ#T=>TQd39?{@sVPan1|u0Gmz zQtn>ny^iL_-lINiPRyL`_q;IM;nT+~_%NzM-T0b(vel^5>$&h27e^I8_Bwt2A|2=U z(6RGU(E1u%1Uxh<%@f<@$rL7|&z7l3lS}1`&!-ezEIFf0J$&3g)LfkC>*I0dC80ZR za9$l{8vXFhd9L<$S1X}0o8~`>2hu;`5N_2r+S!5?M;$#zB8wAwM_(xQhvqx}9ygjj zp}TBHQjNjA0UdQ}%C+!JM#6eLYnF7#+s~Oj(d=Hmdg&_E`ofndCx@`fotMLB$l*?3 zht5~+`7gC!bAI_)Ldlw9?@Nn~mtkTd1XL8WYVW+=(?%h7KBDuz^cBzrGc(6V%BqQZ zV$0&_8iZeV*rZ7T;cm9$sK?O;r<*!^_ZNEQ|h}6Edb!RQcG)s=a0Rx@D3Gft)7M`_X$WrD{w0ouh~S3l7gK?#3Ey??xf(Tm;y- z8Vg+Xm43`$^h(d<`}}=Fu};>)Gp{zeyk&Sj6NeSjT8iyBZD*;sKDl3PQD1Y-`)0DX z3tQV#DJDSlX8?KP9E-8;^0~dfp)53 zRj0vXoa#)r@yhkv|2yCMZTyY;yI6-Gn~iaDXvMurhdRc+k;Ab(;-B>Q#y2u@5Cxl4 ztyH;38g*OSw@vZ$6-qZsy_7liE|pD;T}Qr58(WWtAo5f%Z3_t7PPN>4qCM>Q_Q2iI zSx8rnxM}zFy&8J2YlFq-ly!nrB)$ovY2{hE#bRam8Gq@!ON#El(U(R88g^WGv{bo1 zUn9*Qy^qT7uJWFHxVR0kbZ+@*{ni_cwyZAkBN>h;Ja&98hdg$=c(FW}c)5*lC$=dN zmI=gK1>1!sOIMnAuM9YUFV!KAace3pb<-CT!;$v-Q&2?TQQ79|9`46wk+3ctd>s;G6aB z#pi3A)?^KuJ2{^%y>QcGL*bJa3yUrLbmD*IWuFSO^vO?@(zN=2bFt<7&oAOvC7NB! znV7t3?tcOGy6dfr;ELHqUAC3~rNbM2c$BsjxxK@0_Qp3K+bsz=*oW5?#4o$B*Y9N+ z=Mr)4$ep2lwO^aMMnh`N1FeFcOt)4H^nX)Bhqu~Gx*vAl$@?}TkEyAk*IA|;xc3|> z`nuDVyC*=SHa(alz;*ly@n|ye`C~J0{H}4423zGdi zcfQn^j5EG%pn!`$;-@cX70#LbS2=c#;E(jct9_iP+?T`25$O4Ro2dQNLw8xlsLVgN zYFtj_*_oSD+$I>#lG5ufZbP*}vm-S6#uu$%v@qwEW0h`ESC6i1zb+})#H{Ps()2wD;}kVNO^{ z)sLr^qG2@g%*U&&sedsK<5&SHmEL!gmOe-Jnzvo)Sy_y@su^5%Th8OfqgGpsbJf4c zjr~mAI+;R+_dA`2FKmgr&}(_IRMs*l8B ze^(!oidU|Q>`x>8wB37MiSV>_|M47Z*Zz)#{I#QqV>COPHazR*`+Utm!?>u?-mVmA z@FVLln!6WyDDTyMd@8&C=a#dJZYU4>W~_g=Luq=o$sXFNjn*x7(e{46*Er#EchAke z=Z7yf*U?*bu|n1jk5{5dSKH-lh}b5cY`*)%dNcghYxMi48(fp{&%+;thlc7L5gIE$ zGyFXwyp(q6F+SW`r!JksbrJ6p3ylb`g!@L^6DmqbXI|%K?d`_9r2BQ1vf8zqsNXZl zoQ+!1Z`w9y*<0G&JWjH1E`MxVOw0|NFgR5g?RsXb3wt#D^@fYq8MkcT_C^#N zXMA}){i|Y!%AbjHMG8$|r#?gQ`aKy+Bysj2v}ym!9l(3LIT2XN%G zlG)!=ZB1IcI_9>Ry*Wr>YSmqFX9(qPH1U+yHr0pPtDF00g>Y5ZVzC7tq)T7YDvHBn zW=y;%{xj+1eM5%~xdnf%4Ly;z%f_INR}ygFmY|U-^x360-*TfdzGnK^W!y3I$HW%$ z;h)#AaYfflC$l+vFY`ZcUiYL(G4GIL|DL$?A`fgx@cz)hr%r!y_)zKhWtD?v{fWFI zkqNXfirQNk=ef-7|NQfGJnUaqgOM>Tbpgi2cf*q*)AM&xx|IwiQClRrRTq~4;1QT5 z7xvLG1Pe<+}TeAp(7 z$O8nC*gyxPNg34U>J~sCt~eFqD1-l!3EFUvokk{RD~Y+q(0ELv@Wp|4Zb;y6*x({f zZHFXEx5NsO3eHU_EWCLj0Ujz*zpd-34sQcmIwUkQhn}8VYr5j+3`H5bqJknFFH5Jf zbL3?L4qGU|GRG!RAQN!5g>2D0u}>F5K~ivy9zsV)G4P0y3B-jkl?G#l(h!0v;A{eO z3ye9dD_;z>o*Pt;krfOgPJot0PUeICZGtATjEkvAu9uL4NBIA z)*#z;U%FdiT;Di)7?TbM`T3xTmtOoY9Ro0Czu7S8u%aG}&lL|6jU`-c}XGilsWlW;C>^d^amM8@`11 z(F8P*W&>vrmn(uYG-!XJcWx7PSqH)}ur-t0+VOb6P2n?#!!ZTM@HK*kMqorP`~jAz zM6fX1(xS%3Z2i-V%YoQM5`5tT0E%k()Jb33z|#u2iimU6)9`7jJXuM3)00#REtxMF}MerN6;mXp@rV;=WwaYbvX+k@O7za)oprR$_ z=xi7$q$=8=;c<#tZ7sNPnPVcBgw5nInM4;ACrCqIssgwO?|yMN$ByWJs}MmTg`~9G z9Le){OEX#U@mQFn8DbCAYyK3w_AgNmu6dJ>#Gek zF=#!|P@!fM=yakd26A=`XqEGkAYihEXDe_Bp{h)l$w+XN*nB$h!wvaRnGUfeG8#j~ zT#>Hg3#b2fTP*~E%8T9q3I1p8{L}g8|CrQX!UzkNhn)P+;#^W>K?N53&TEqCs`e3eP8f@uqe!Ha>uJLtbV2WskYGvJC;hMi7zkqb2~GreEY~tn0F6 z;KZHZOS|^$e16RScHNVkw{Z*69_a|GIn8eb)V2!96?GnG9ZndjF_VpN#e{|mQ zNP2MIXnG1S4P35ith@`)J3lx7_f-DFSC=kdi9NAQ^?2?qrRwK;)PK<(hw#*OW4x98 zh;s$S4nfLZN1WpejZBNTy>-eF=NWIke?Z-O(&~ZpWS8JpcSL!zEvF#IUDvducjJztKBi2Tz7CBP9C9JPD!hl(J)KQoG06j!qk5 z%Z!)=^yK4J#IX5?GVQgVw*-qDktIek?OAU1E`%6@))}uYzV}z8-~{(&t;(^x_qT44 zM;iOa8ctQdy}h%!)a$3WOX=mU_|rkIgtGs5j%|;!w&fuL*7%>)|LJ!HYgUl>`Jl!- z43)hO8TH8+@xHM@`;&^oBIbFIqlsR(3^RIA7dnrgyZ>vb`$1Rtp_E=qw%VEh^HjY;l*U=ckYdhF%zg6st<;6vMMcm^q*r0SI^jZ-eYba$UL@Od9>%v z#2T(D{pT<8OO*d(Yeseddc-yB$;0SiAc3*lFmJTqn@nrFo0&Tu6VxLw#N~7hg>Seh z8jJ5UAA2O3yfI3dc*>&_DSt!#2R`OM{@K4_DdK>p_vXhQcuq6=j7+#VB9A z=j--Bf;Q);zxKC@)o8ufP^DuiBkz~&-e$Y2aQ=mKg^;(@-sWXcRaQ;MXyc2s|8P%i z-WztBFfxBJ@Alhj|NS(>?0-4&ztq^yyJr335{>gTe_q}r@v6{VV?%uV^oQPGKg%`~ zX5zqP5bGF`dCNp+c`?l6ms$0`=P#& zgR4CM^bc{5398J{lAcvNwJk0Q4)1<^vfekUFerA@a4?DJsU}l>Rr%;r_s+i6S29j`CTG5X`8)8$kz~F{-5C^Srq-0=22)-tl8AjwBQRe9t@J8sD&YsBCEexeLcC zGHmOD(p57W5)b=jm;GxUW4wqy{ zs$HAH>jh~s2d`f)>7{INKYp1t`ja2dogy*s`fPn!u9+PDA}y8wC)Y`I`A_BDE!kGj zb>5o2u`>U0?%?%jmOJy^ELOLLoFdo$nW#&O9x4jH%w@4Rv-eHtd`@j#K{kIOTdL6! zYiYm1E93o{Z*R&Q*7;3t>9$FCPd-}qo7}AU^cwQx8?oH*_g-arnvI$H?TQsPqdKCh zz6e)_Jn2_;ZtDE1%QhbC&?-ft4Mmsc-&i~P^Hf@j>?&9%!!1`Dbuiz)DIy_#svZ;; zh|fB1c%qDC5BEc3xarV!`-nneebc<(U0K%H#}Vg_tWa_Nbi(#z)vLx-hb&lS2}&Fe z$N%Jc9U|7utmn~o`1JEOZ}1(CW>@x=SAEE~JiL+Mp78seP>`*@X5ap& zGES?4gYnP2OOmbvUuPASYIVcJy7-OQQ|{s$vZQ@ZTC1q z7X+WZD>9ggez;SvYy(~xHbEyw3>PU(Z2h84aZi6Y_3G)jKjK5aGq>+pXdMXju+v7i zQsr0DZ(c5MfBVKNnTUQ8x`Zj`8lGlJO$xa@ToL!!@Vc z+kEt&-RZXcp4V+@G@JN~Yc_5ebcR~A{`Rr7zGe|e9AhCrZLBsWwXtwn5F9u zkT&)1=iOnw+E{b(;QgsSOmJ-mx;1fYOACF6iQg`Y`%2MT!?eF&L#?)Cio9L#|JCFl z?c)dA+nu9c$;}@2dpw!lL1z-0Ooju9<|k#%m6iKcH!)mF9vIU|Mn5-RsZ@Q0&+FvQ z-WW`&&N%c#Bf@)dzuHe{q}_%1WiwT~ed#Ofp5WIrO&-p^Y4g0~VQ_DX(BS_d_Y_?^ zanjV4(C78(xrUn300Bp@i@TW8iV3s&a!RSVX*uE@CAa20X2WiuyZNsQj*oK{tg=|n zw>~}g?Qbi0?b6=+p?{QLv)XJMJ`Z&q6!eR2p11t-gEAFjMWs!+FPR^CfT0zp?5S9%IMS9)`F$?6Pkpt8D%+ zi;(mOL)h@9~8FM8&Hb0;8zN3s1ns^kiPWq_6x;D9zgpSuTxxCkxqExt#|MSVe z@`+vz33hg(huKR$>*eD*ij2>tsSiJGntks_I~FC2O(xPr9Ub{Ucw?dsS4#bxUCcs1w)@V#bKB)7UgNtD zXOrykq-QTarPisq?7$t(mD@u68y=5-f~ zMSZqJ(?sh+Bm?&Lcmu`%1FCRLrbyP_PA$u`mDb^9?cHb}`30yc*GQwiHMkC$2D5oc z7<9v+8=0VT1%_^+X$zXl1zgU!T}G)R3+TKtmJ~=Eb8tGKi$#IKc@T!E!?qt;2C0;i z0D&*30jiV=8}zVH$jtT7mOxh&HtHeC>$9Sj_tuEvD>>g?_)axt&vgm38#xQP4qqtXaoZPE zCDzFZ!0X`nBf(7~bib8s+&(A}@?-?xvf4WMc_3Cul z#2a61PCytlr7!`x_O?AkBx|#Q6I5JLn-?VoJ2IN8)~#DkAmH2j;7kO>2z14O0Vc2{ zs2UEAC4~fyQR|68b+ZK)C>mM{#~o^vs-y%v`T|pyt+MBof(*tPS>0?lzQ;97((N2YY!!XusAmWNv}?J zZ|lJZI;b&S|(Mi z80u-RV!$}5Fa$@^L!l@OCIx2>XCai%0j$-=-wj;7ApM2WIswA9oKk@l0@cWe&a{h4 zD;wu;qya{1q8OV;1_ic@LUltU%xm`d@Eh3@6t>R>X-NXJt2v%ZMWj&|&JA-M4hHnB z8}ULq?CC*gp6v=yB?(kJc)Ey{+lW9{V;)A>e*bJUV5Jjz=Ks|H?G7T-(PFvvOC{iI* zcfrr%3QcMxxL}wDsckRqj9i5@3YnjB+YX&8F_Qww48$r3lig4R1O%I8n1sZ`^`UKS z&{YgGmS`aX5MizaUDl9~OJswpxm6DdA)iUMS#Al?iDL^PD7-$*oK6yOc=&*#5U#0d za2+8^#o7ep;#>v=;#*!n91~_A6KJ`#M7FbTg&5*Vc(>YHKNkW>hlG$$NsQ)8Wdbc% z@E3t1JfFy@g$oZIeyfZp!G*wSig9fPA4@;XOu};knoY2a0t^en;}o=1U?Rvi8M4H~ zi?`z|q=R5f%0YnZcnqr8R0|B6;R`Ngh!n-y7U(7nc_<~&!AON*c3px*0wBznQ0ee^ zMaVGHPVBlvScvIV{&k-#p4&OMXRc^&sJ7U3&jr&B36Cw>H*-InUlsV(;g{%D!%7hy z?`HIJaFS%T?c|w=hP~lWTui*S?a=gX5f_DbMRvB`*qDD$x&Onq>L5bhkfsKin1kI~ z)6vmz=dRWMFFq^qcr&vdnm??5X^_kP{`w6xUwP$kOEEfi|Nlrj_h_d7$B&ngTQXyA zr3_;;w~-Pl%w;oT<~H|bZpkf4rJIl$VQj96+2kH`O(+YwRR|H)he&Q+=zjBi_5J(`F=c~4~_175u1ZBz5Z3_furpM<>j;)@%ynw6F=iLMDx)9S#h$9;1|RS zN;ZVLB8#xeZ-?A=Ir(CRF4H)JnBaztt2HQF#z_4ty?~%`ez}DBZD}I9&tl z9Y0mL5~3)eSh7~~oD|?u{`%z7)#?eV%);#-J%e-VR1*QiH)`6;rf=e=^l$ukY~wu` z4(T8DlnuIW_rB};Nk;%;Y|I&+xlZp<`;a9N<2%`Y3VpMsUcY8hGek=YEqGOad)GF3 z6PA(r6@yRpigjXQ*|loX1&P?(@xlTIM-@wN%*RGpl%B^2-;&olqPE`=UnZw{SLh2w zT*PIpCh=O9&z%%yU2WTob_2KO^A6T!B^)S-PCEv=5tRA%eJ2x~m^yX6fPlWn*@k~md+1jtX4OsbZ<>j@XDIGWX z-(E7FS^QhS;|BY`(=#>dTL~VYkKM2>FS~ajeYKbnl_rRNYpoFV_|SeI zM#SJ}Gg`T=wJi65We#LEO6T#;K^%N z(zm&OEmpUhruTjtjkzB-7$@hY)qnTg?VL(~!#`iyJMR5aD@lIyaJ|BPFgJNM+)*8` za?ryfS91M)w(kq=h0SrFgU%fH{I6{z+}O(dH6zc9vg=)~PD?XIqB}|7Uz8e(^KeUh zJx;#GON`!@ws@lxY?~T*vEk8$5f1_QoA?*CX+LbUwkOuJvr!Qt#f<~z0yPX`f8vA5U>2M%zmGef9{SA-VYzNJXx%5^C%ePuaZ#^VMOk z<9Oq`%M#wYNYlB7!OKOgZzA`ejk}&p%R{<372Um%uYF2%92hx& z)frQ(^qVCO{i&>IftWJwATf&XIWdv0_OK0Lz zo)sMF?#eK6vh7wV=N+BAM~~=A_?F7i3O9^--B7cfW&3Hs8u?V@>x1@=jXz^9glw7n zhkrOH$cRg~;;c4SH|1(}CeWI?RW7sganRe}Ozn1^DD z+_%&&MorX_D7A>3%!Z3kq7~yi#qTfN3oukUo!TjI6rNIABQgOks8Ofk_swfhaU{4 zwZ~-d?7wX;^R47A{IH9h_?Wllp1q}R|CP(v`TINFdU@+h6)i{M#F)eL%J6(@*K&K9 z-20>|jW+OH;ScI&-Z8gRBDkWax65KB$hP0*`(7T?HHzSM9-S+*P+V2bolCQkUs>O0 zhY)ovilki>9gUqP30Ho#)K7Xjs^tv+Katn)R$|rCzmO!8=Mj1i?^4Pd3-$Zy?EZ5x z^db{Zskn(CW)8T%H|sKcK9v!Y(z>syH{~;aSD${yRsB8_@u56% zGo@nULQO`OraBdF5)qN@+1}pw+$@>anw^t8IiXH*;Lqjkmi+Fjm2R!+65)$#oOm4k ze9>akzM#&NvhUW$%3s*2`R8}Dz9(%@u;|zp?eimghOOy;C)#`YdU+bgLq$AXK^5OH zK__OSlKi=X7ffyxo1K$Qcq!ubdiWq$V-R)L^7A_ny?3?Y&UBO*z{z|ZR_!e z=X(Q|s-t?pJcE~I3=(r&Kz~-^%YDxv^=sww=J4^8f7agajN&B7^F7xU}VkHwCnyP;Jt?*7Fjd(OB(megL~id;@4F<_O7~9uPcgYx9)Kil`}HS%c*g7 zaV^d!@2igtFdP0v*yCzkYge}$AcY&LqqES5?Xe9BhQz&bJZPRQzCR<}6UA6ix#;gD z0~0zfe521;TY7Srot=nKs>EK%gBxb@X(=m^LrKLa%RVj+4Guwc2$VVPM@avYqMI5o zT%9Nfzl&_V4{}dYN-b1s?B1b;go!#ToXFoNJwH2es8sj0Yt&-&Qk*5fE&DT*t*~Wl z{c`istHqR()!ac?~Fe2s60(=Iy&wcbMa%*`w&}5o?g(sTm1;zw#GAubbpdDua~PcJYe6M zH@rX9@U29KnR;%&VF!}G26N)LYMTvF=SAPTsC-^$MPwuzN8&<*XU``hf49rv+e`0z ze(;)cyPw!4Wo2ZMH%l5`bFr5Rto!|CGS4KB0)Mm)C(RtYhkct=OCC^n8C3|^ z@pPn(T{!Upjc=^Xr^G#bg3`xZ9@Q)~QhTebV$mh<%P+rrA7*+JYbx~4QC+1}fbEtu zo%BMF%{y`Jv=T_YVc0=t%o(njZ9}&R+EZn%&gbs2lyxzkXrZ=A@2fO3Uy4!%;zT z2I^XC*$IAbe|yvw|7@VtlGnoanLpl9(vTdyx+K2PQZviClyzmQ@Ne+3&vuTbpfurh)6mE}^CW(&utEFj^QKex zZac{L^v)MNza38C(2xz--MWXg$=75t9WVmA>>SSSC+AI6#WJN*1D4gJl2fxW|!&veM1 zKYjddbU+C<(L2F`HQ&iRc*5(f{G)7ztr^W{sY6TL2q-6fy`_2o#7XzL8++l&byY=! z5zoq>sRs*%Oo!X|D9dHvH>j}njfwdZnsHC1mL}HQ&wSzcoP4J{aEYx+L(eVaKe^7^ zKEdu93nCg1ER5s4j~?(m+n4d=0%GP_{`X_?6$8=PhE0(^w_0;As6A`-%qvwudNc_4 zG%DE5nEh85n${Di{%~8?mY|$|ph1D`ON(Y)pDV&+a`>r|lDuKXZ?4_(?|-g-A>0kT zy7i}dKuQ3_dD>8@mfSy-j_d`Z08oSjKMv!as8+7HQ~*E`K)aDmVT=I?jKiC6(AOu3 zfZiITs^`BnTR#<=zr3qTQn$RT9kxFYpnI9g;@uKtr%+%$0Lfvf6LEkGz%@{%<-$SS zfG4Kff$}Z|uwEopbrc!2W2sd{Ag>@0z)F5zO_Mb+@#f@vV8*CbcBFsBHy#Bj0a7cq z5x9Sz3_JXwhkFr_25@mq7z1QFV2~dCG%DUL3BO#R^eP#QBA-3-^OXr^TWLWwg- zfHg3edVn@Bfkgz?jGj3Kr%-;dr5LFH5m-6^U4n4wU_C9B#&Q8_TKO6uDLz4}htO2@ zZ?KQa0=|h5YDJS*&Hq?NQl+X`Zw{MR?*PSv;VvC0z_wr@x2OW>5rF{WN{~UQ@eP3q zL#^A8pbAy%8RSO;;?NGps%dH}K+thnR0gLKaE*h+9Aau}D&D6stWb|SM4 z$>TsoT*LD)1l3m-nU$^WBwr?=0Aa{$B=!aj1cTI7gffTun5+X}%Ph>G2Y@uQ1u?3k ze9HU;l1NU)6Fu;Nyg~pk^z`CvLEn_3*QA373?zvC zoG{=I@hm0^fhPiIX*G!bn1E9Q2Nrmk0Y1qF%2)yjUO)_FRf#(ZekK0vZv%G%U?V{@ z8Ab;xgj^a>--2(@Kiw|}1f8Q8-Z)d1PNQLX^C3iFZ~$IXIU1KXs105_I3MIfq#fX_j4 zdg)F9^=WhziL0(x07+$xMp1i!GzIU0))tZ5S0R-j1_i_$R6|qtYsw<8C;MimK`o>e%ZaI66CIgY%xxrhIlWi-S-BJq-BF-kCqBhQb>}&gf>i73xwT<1Ta|m z?}p6-Vg4inmoh*d0`q<_R|K9hHU!HdlRW?ftnc38)esHxOHc#FgP}q%4?K7PV2ZFS zhbC2H`_trLWNTQZmK1-!(#1804`ezOO`t~Vp;-V)vI4b!pfQ1QfvE=IdZbj4d{*Q` zfeRN$Ronr}cqPh(GMMUN#U7%i4(dZiK=7&nVWLQ26AuAJ0_c!V0~L5z zqMcP0y2kq7a6p2DD^v?KXw_@gx$bQ$sdV7wQDLwNU{`P`r&sSE)ZCoI1?el;S45;% z0jY)yg49j`PeBqXWNURA7u`%9B39La`Zjnp;U?1CEl=_SRHL>w9ymvMLkS8n5bv_} z97OsH$f~leiUP8i{l7Z(klGLvaEoY`mK=CW!KO^0h@j2Q$QB3uCg6XeR=rdw3D|?H zDdtz>RI|ElH+re56R4pmCBUbE4oT1bU`qhR$)Don>epzYnJz|7v{PM^;uE_r4HxsnMe2*?;^MKdg#EU|L11C#^?nIeitv zcv!O+5R6-Tf2YWb`We#_qE0pkk}j7#4&=IsJ$}X5IR?9czxrcO*7pKg+anXzp(Be! zwspK@RHaiD93>=h`T*RVrUu)*o;0-`Lyk5N#2z0aqEWvdy7Z$CB7avk=)E_ZxVZW~ zX{9*V>p!?g{1NWq4*`Sxy(Sxz$Ma)I8dfHiu4f#-j5xVw?+34b zk6fDn9dW&{A`<78DFbB_atGdJQ}?)?uh0z47r4+$cBh!Dz~`}Ym( zQrin^x=n`81Bn(oBF>fJBSgOM=l6*TN_UtcZrD`S1IJVph%$!8${jNSV^ftXv=mm%ali9fyX6YP((HX*q;g#%bCX_eT+(37Ib*1>Je=h1E9!f1!^yHe@R9 zdwya5Q2E&;*F;V(;dIwRXOpJKOj5FWa#Ahf7m;T~-?R-+$MkLAHQsyb?a3X}D&)l> zf_qrl`Vo$jMV0CN_<>t8Cl{^+XdO~fjZr1poHcRR?EJ&^_`VsWHL&P3*8kP9GYZ}; zx@$JxZu2O!{J$ova~AI-ih4&;$jP`Av-k6_twR0MuWbC-FtA;yc*^T}gKyifI_T>v zGx?-2$2Np+VuT~H9`MIF2ks%t_zR&kVndRnn0a@J>H81Ei4eQ7vbMBumq(e}dXRAI z>=3P>AftZe5jo3RimHC6th_LyxwUX}*Vb#rQdFPUUMqi#vfa@K^OxzHNf; zOPqQsrXG?>u6w(xT*T$R{euxy_fF&KM7LzA+=CzLZ%o?o^pbJ?bbC~1PfYDx#|sqc zlgV-HVSYX;_vu7j>ZZ7|FZxL+<;<>$uArsbq0G^4L#M=~vU4 z$~i{(ZUqU4({Eo&+e;L-u|N3tMEd?bi##*ute|?ZFJ9F5(qqq6HhxkpK01edWr1KB zTtBaKr>U_i#pjD44y@_SHlO?CIVnIg_|RY`_Tl5YeuTg$&b0ii1^S!zqvuA?X=LBr zUp0_=&q;!a4DVB?*&~$di~_T-Sr65mEx+?l&0($Kb%i*z?#By33Bwz&X2-)YZbtnZ ze*(s#4;<~_DGEYCGV02g^yHuqT`CJUD1})bi3dL}?z+5wQd$>lAEM$A8%zw@{474B zzc+7p*n@t?q;#3?c&W;Mzu_Uc%i*5QQUUx4)qz+K6%-AUqdtjO+xnW5C+;J@`#}}j z+69sS`3_MY{c&t;u{=P`Bj(cl>N81`y*|`;c}3nC9U|4o#&s0m-QU@1`!HZp3r55# zU;dPq5Spj*Hpyn?i^k$<|4MHp{we0Bb&Eoibohf#jMu12ok($_{tw>{soliBea!&} zHMM4(y__H$84I&_m<~ZTgI!kWS1bcW&9d70}-p?|Uz2eO^q`LGt*{`$}-ZWv_Dm zP=6g;ua^F;<=f#Thu zvrl8nm$g1)EQ24{%i0}#DH_RgS-3KpeIg6FY4nwr>+0@!*cD+c_!=4;{rS7nu%pFB zYlP_RukE7Uvu-zEE!fH-2ap4LF1o#yeP&(8s)B*cSanCM$wwA~F=fP?rSf6K=t!r? z^~}ALM-kg2qd)R92?wz$3(w{Pg6!^8^S1Lew%-X{t#DV8x*P7EEie9{Zu`3{WcPhL zpa%!|VFz4BAH5b>NMMXy6=}$N;9!%wS^_2g#%)*Z5L*i_I);p!%WQn>nxIEW|Me+r zC)OT&LWFVM?yYI|fU`DvB9yXUL!JIw%JS|nmxom|M(Ka|-+G;o!4=<<9`oZ5oa+*3 zU(xwunNodCj8YjWb5TI;`mae}Wo=C}<2;&|=;pW$toYgBucAHATw^=Tzx9lgCDnSL z$6PC0j{7k%eEfEDiS3pVe<-(C7M~z7zNL{zvm1N4@#j%yI?<4V}Qc3(yxp%bh z>#uMKojIbnkN?-2+Rj^6w2q$-M#9#MqxacV1=r$TQ$`N|XY&wAJBlw@4SDiKKEC>I z$0OZ>oyE69Pm50s@n?)7R)hSP})FOi}3D{w4`4 znjIW=y-iT3M!DHzhZF`&)WK@#&f3MapHe+mHb(>1 zQS$*(?vo$~#Lv?OZ_1gR*1nQa|@;9jiGG1g2n zC(-t?Ox7Hn{ZL?Cb*JW`->L|?l(@}1Bc9bNBe(vB0KQL7C0I;6=hVsUAGYt(wbsVq z5m?*5q^^GcLRZOSy6NMJ*Yk(o{Wu{WAQ!y8d)78~<=gL(4NrBVG{8&#Y)yU-s-z^! zPPXTa%SkF2<;q&O#6J1`iHRwt{r6KfPqxF9D=1*Lw4OeU7j?^)Z+3C7UA_0sjD^_o zhn^98GVs~5X_wA;aQLY!X@`|;FYkN3d(ZM$UoD1bzgB~^V`{>?GKWRkyDnLd5+<&@ zga@uvW8un$`&xrlM~3=;N7{$0);GQND`cxQXszAfv-l=JR~IQA8cdFCc(uTd_Izo* zW~7Hzjk`K#;(AEIIqAY%PIVMtOFKwkwNO0vntt`Q_)ltE{NAz6MEKvYi&zeQ z`+?U6mOHLGGaQ;eUmJ*%)(Y2uzzESpIUO(}Ug&F0@j2_WxodiCN&bwzW>3gTdPbnW z?zB7BQK}W{@$uuL&UPPX2B+>h^44bUHwy=~*++%#BwXwu{30iRN`1KIxH(f=QUiCD zSv)rOAoumA33d_xYqsdm)2popxr-rYV{{RlF7K)n9-S9Qu*~zVRZP-{7fVmVi$Yyq zZv6~PqV7`H*|U2^{IE2|G;^!S%sW4R(mBtX;4>XL?>y)io}N&5KcF|*NY@J0OEL*K z)wxvWFO3LqYc=}XRG)Qs<4NrK;TLUvkMvcd)6_0ei6bd@zqW?fu0l)qTlKze+-ctm zH}0j{ZcghfmVUnAWR7;KJc4}NIWr=+7{#Ca!d<%OtT;HiGg*w8TKVCVe|OGD+oYTL zb_MmY5_L)duI$l?a zazPciDX*;-mJP2AZmMCAQY(=z0bCAyz6v;OL(pF2N_`MOg8Wl2O-qysW0Kn1tZ8&0 zcqPyP>Xi)y2~8lJA``R469GrZB6HY)Kg#hS)}q_eXrLkqVT$mJyn2u-ia3&2|7!k|z*6RM5Zf{JD-kjVcO z#vTE@QBWTRUE$#TkN`j(4Uq_3PP3dSJ(c{=lTwKU1fw-9ajZpgawbRV0zc0bUuD zD{$wcn^Xly@=avBt>TU@wG?>iO)~1XB z#|sJ+m_fFtX9NBUgv}%H7J>T$C`9Nv_DC4$tPkMm3_=sAXuAViF&m(D`q3;%(maT5 z$waUUS5xDIpd}6^=z|)$swkID3DF072@!qJ`2^NJFHTjJ7jJBc!2;q0E_T2WR3Tke z|4k@@><=_yQ+h_7f&uG>Qw7EuHpPSJ-UiZF&=dw*2Q8_8DjNV6+M{_Bvf%wm?d{>u zVHhBF2T>}RsU-bd_H!k1t3aPx9s#B<1u=xIghNumg93ENfbgv1%acLKHa0)VwTMOl zpjRJ30!$+oFpw;eGV6eCN*5gu5*OW;=VdM4>jHe+;3!hr0U*u->8x6hrBI$!f0_fT z+)f9gd?a4eq5Q4=>RSUfqkufkO$pxZh_k7?CDIn+M5bPkJ?Hxk> zNAVQ^;*5Z@L!p6Hq+1vTtl3;O{`{m1j8M}I)+~YYH_jf3KsqMN^j6~C+w!sn2Wdp$ z`L&=lVE)xo!JE-D3ZiDPlIW_6VnPM~O9OL>a_UGtD1ql9f#-z>I>HpD411gl%N7Nz z0hJ)i<7Qg`xrhtk6VdiayHrlI)%-kw8KwN)??$F2%4;MjfH*HT8;C0wp&;1=n|+{U z4h4E#Yw$akHI&2yrlGcqCIYFd+77(1oJ>x(IvspjILfFy!48HdgW0`cU^BLw^8lU@ zkmds)hr5Fa#XT*DsSQ%ZU{$z01l);OuzyJq2>`$_ggs6Gi<8#qie|iB(hzv*JXH&M z9Jt33a-y^lDt?So(IGXUFJA$w!UKstyunnux>_ZP3@)h#f%P}!s&n`Ym3S7LJwMg} z<^n`Z4fCB0s|HyOp*`SK5a}So_aK70J&q2XzQ7e7LIsO`E(jD7=#;e3O~8c0&8Z>O za6Ke42$4 zLB|~k%86tzLXEW!A}b36uAGSkSu=2^h*80GB~TDED2rUG9Z--b1`}L$KoSi;FX$>s z{6#t#n1xfQcs8}#3V6mPZ2d#=peP5NYP$@Q z&>kiiUKN;m*K~`&_+Dv~xIIK~G&t$Oz<&pKzTzO?4i-OY`m(r_$%PA2AXTO5YWutC zOS?CHsv+c!|16$ef|(>u4~O0mEtW3G%+C4LJ+j}lx=1>eu}jjLdgWN8mszY}Q0auI zn7Jzd;}*X@z^0+ZjC#7LJ)G0OTsqI+mN=@iY*Tc{1 z?-w=U(t~1`_b~R|Ik~M9EhJs7EB8v{P3fm867HUp#(%oMe-KRcDoVQI2v=_1CZK|| zYNY5DvM)Y{V~&M2&r$RIksTd!vxlNMquUQ3kZe(uJ7RG0nou&u(sa8grL0RelyfE1 zafYZ>?*^81ep~dNBX8)$sJ@dBQ4*36`t=_oxpq36AeCaYX5e9@8=EI3SpCg@ z(CH0&D1?zhe-E> z<}QuM9*|7S$dVN~oF!-yQbRzAxrDa zU9LTqd?I+>%pFCs%AlBu*=_|Kwld>H7A)K!<7-SA?3#+)Lz7}4bUbCCr}qEAJe5m6 zg$*oF8>v2j+CK#UdkAawy}!TY%Hu2dPr{81qp_co?8{XrhhWF~rPo-27GqXc4%vMH zTdF*4oIwBaEchX*VJrS+$$|)@@|NnQuN8KMH)0P)C^Z>F)5Ra{*hoe}NOfOO=ggyO z*hdOU@GsYoNWOE6vi_wA$rbi*)eK*zH?Mg&2;;_E4)s+O?9EI~Iv}h43u0)hb5++Q z#O&)n$#Y?g5^o~1i~J2g?n*SY_cyU(!BX0)BW`NU4-+5nuB@Eehi--Ma`#UAYN{!x z5WQbTD!RhHwBfF;Ust!Q+p=Ypa}Qu}d$gae*_EVFaf0zj9&JZ<-xIFA(Kvr} zXu|xB)Kq#xGFE4cwl8(5F0Y?$i1i8!E88CRf_md9Jf0CWg`>p%9O`#cb?h`I-io$3 zsu@w$H2gFZz3761dG7qW+l8+4a{J1%2oX=2_72$SjkdlR!ZMJ{o$4kw&A z<)&q-ZppS(6rn#m9r~qE7sD1mWn2RjcT#zBSiZ|Yum5b|M!MnlIH8fJ}U+uZ9 z-`uoEwdl$O!6jM&kx|P1UH>@Mx(hZINB-%t?BjZ$Bz5cZJ=B5PGC{%V-}U&4hQjgW zUku+kGHLew)3s46H9)$>#y(F zZEy9T-=bx!mCw_ZF1OnOPn88!?ypb_{$RHsr8N1U#f=KF0|ln-<4hmPrdp++J2dM5 z{dY`HtM`hCgr5JvgT7by9q%@5#{8L=l8T`mnHmuFe-OD}TbL_4seGk5ao+O55fx#D z#X}m@&7>V038HKWH0?-Y99${C!cR2tZ2yB!sYOqSJ|ro`MI>t|Vhy@S#aet^QoaoJ z`Hob>1-I=gNLQE&?5YVywQrz*H)9YOYz78}YkfKidHX+=emc`Agz)3oSZ}i>>Q`Ka zd>4J#cN?GZbw0OM1yZqW?6>%PL9s<7G0}M5(uD`_Tx!DYCU;$nc@H|F!7k%{8C5+jSH4^;ZJB)YNvR z#q6u`wS;(GY-{hCWT@Lx#=Uh1zd~DZC*IUbLzOD6)%r>bsiVGLvbf~#K0!9k?j7GU z_t=?GdLO1_c{@>SL{`E9(|&44%F98&%UyQZR|`kOdXDHr4(=i8;T;*7`p3xf7J(F4 zv5=+jPRGZa>61A!rfnCx?&>=++>R@M|47%2P#3Z1SL6$0uhTn<@bPv`fyOLFAuUsZ zlF0D{&E3B~Y4AlqRCktU@3wziAs7FIsUB+ZF?l{j7T+j+I*=atenS^Cuz z{iiavTl8XUXKr;1jtpE+;`X<~CLW$1ll`f-?``~`Nt$i{YOASIQoMHN0fj`fZSn5% z^iioVS1lTFS7bXC4Xz&A?}rmP)avrah*J}{ezQXj2KnqNF7z z!`!F`@7jUH6IW?C#r&tj77b}w`*$bCET2~GSA~4~Y4lEL=dDVlz#+?Lw~wUU5VY=H zz8*r@A?>GP1mAt9>w$OwH0#8(GxTKXrs2@%Z%Rdpimt!>;$$$T!c`l|M=xGF?gNYO zw{3rTSR*1zLj+3|5S?t5qKv5M2&#}t%tWe!)Vk)y_6L)~5*FU)YCJKF9NtY&QtqfmL}ju84|Q)vtgpsdTNC@1)bfOu_2Tpy z!nI_gaYdbry}npnJXTF6+;g8JGdU_t^Ob9lj$pfBzfiIvzG-~5{-dWR{A1)d%C5tP zPag@v^~3tuDvbd{gyMO|-J%Zk0*^Fa2e zzk+Q9cZ_Y+-^r~Itmz?(4cYZ>t9{<@)Q(hnz5NqJa+UBp(`e8X>F(0jsEj`@GIQ6_ zC5M%D{ygGmkhxUykG!g`w6wMWs#3C zd;idpWa;O7p^^RHd6~Ul?Cra^e{-Q_b7$jlO-cN{j=>gvnc3Uekyd(7M{~kUAcClL z$(2wl@txIIiPw5LAy6YuZn~%IbLl0!uMH|9H@(aXo*&U2NqJQmk?*z#JcL>z0@0gTj132aJP{OT8Hbh38O1!#w-kQLl#XK4LS5mcXvI3! zvnoHX4u+d3X#1J?hJ3gF^W)OPlG7?T;EhQVZ&mnl=>y^SC!ZHS!SUC^9MG3E&?c&{ zw@{cv7heaOG?;~rN6RylMWY9?jEQqseHA>sM4yrm-qJN(bgBF@L!^GRzdj;5V_NljtS3rssE zsV#BzaW9n7*S$>pb|?8k>6rkw@D1<5Sqh@4Emlsv0Adzn{Tz0A-Xl|ss zp@sIOke5fz6zebbuFBTSaOw&^Q?AblHGZ}wd|O<8`g`+<;rXWlw$?WXu?b%-KV{K=bi>Ch1c}p zyc3xq4-~N;PN3sSB5|omR8`N|C}xb+5Ea}Ewa^2^CT}ncgn8%+UT`H*RfZ2rusKys ztDZqdjt9Dli>IgbH1PnSYE=nqz_=IWcyR&HQi=bUszDeOO0cCYgqADHL~~`K>L@N_ zD7CkjDbkZBH3)<+49e&MwNVV{XsRPA^Zw+@5F*&=qf)hTph>RaLdWNjiTo*R96cL} zAMBYUvxC#LkRcR+sg8lhbhex*h+kNAIavgbm5WR;Bv>c*0@EoAOar>9GhbQcX@XjO=cyjsj5{q*MXWe8HCD z^wb`G_k1mY{L$$|uuf(qU^vaO3Cu&rSqe@``CCI1!4e=Bl&x7Hy8{x9lSC#iGOY$gd}sm-GeDxKvUZq1qDLi5GsfdA}PS9My`^ z%LUQa*cg=x57uI|2o6wO;@H?&KM04%SO_(L zu+v8zcLkiT9Lj^r>7js8KG3{&wTH@b2JpaV4Q%5qJ8b}(;=oiP4SXo@7YJEk@l(}Y z0@UQG3_TlBx}6R#r(43dSDwHE@wM8JHZEWgM6n)#y+pS)RWz%!SiA~wN(T9cq^#ap%2r@Vg5W)af1&kv&c{C$w9H!_+rK(z?YV*zas$^|gxP;@0u513xm|2fJ>L)`P)xV72{ zXApFPVOK8%7@lyUa;w3eg$Is!2we+Y3PJ~paz)qy?=`y_#h4#Vq!2VjQ+vidBGc@& z?bNeTI3mFn0HIpoZl?q{15%VzfYS$l?3yYaS2BtKKv@v*f}K&YO6#ftv$L}UN0KrL zd`woo0b@OskCtjZBZ<5IWt)PkEG`??UONb z7dvFPRG-#nHW-c0YX{uDgH5@$zx%kGp2_-=Of7@pHxP(tFVW`4ZHDxPGc|5yvMkV_x;^K*cnCc{ynk6-Ox49q)(hLW5S{bTy+nY zop;xasUT-C6sQ&D7a!ZtK02anXv2xgo1AEIXo!&bb<;hw!JK^T$?q_t9wwF&n0N^@ zwlE^`?X$1g+a9}|`RN@yIr3V1Nj9b~6hz?d8%75dU$6xE~3u%swe~Y`9OUMch;5xXh#+uMc(JBLKHJbWUls5<~v`uy3k$V&k{Z zUXfq1E)zb`;ALBuY%NXdWV}S%s*5kZ`pe-4jBw!(fuZTTSpOXsydkN;s4rC|OjS(x zNI%Zk=A=qwF}{A*!7)EAc09k-Q8m!HwpaHz!jFrBe(q0k5^RaPcInr;@My2#TTNSa zXjZ>VbHwz40(^_bJts`~HI_tR@+0;8a zAiV+n!uz6MuJoX5*SF?AEWCT6q-@Z-{Th10n3%@h`&tn_XgSaPs}tpx9%;jzLaQao z!YX6$S%li>+nj|eKYB&Tbenke0{24A+)lcD^g)=#u-d+q%3#hNp1>itoyUj`Z7qOF?*R}{Ho-ck=%$MAE zqWQ-}OmET>J_yfB_41J=8GN~{o@-X?UmqHBd5CmYy}!6FG%L4Q%M1DRojuW|yYb<9 zYpZ6Ap9gzmss8-bu^0Z^P37}Gy8FT1hAVH6tf#yhS`9pHJed2Tak5R}aY(py(1(_^ ziI;oQ=VW3*sZpyI> z{k~_EF15D=s1Gr0n^L_+_nk02T9lMO|T*9Pi$)pIq@M!V(_KR zyBU!jU-{2@RWjn@@8hbP?90i2oUTysAF%vkfqimUJQ+f&O*mj$n-~|BtTmOVF>PXY zD$bmH^Wn{tAN1Aka0Z@++osMqD&7W$*$CCAI?6%!oSny*X42D$+vhWzGORLrklAF4 zszhBg%2}#Z+|=j>UbnK>NVIK_N-WvalIM3=F`Tg0@}`ZokNlw7P=`Ac^zo6;q3XGN zvMA1ZZ2pCW4C#t?wUeC%w!?<;%v1V>cPZ1(HMf@nIbR?mdwoYcLr%LO&%D!?D#X7m z-d~pW=AM|s8q(P&Xs<>7UeD4~f3Z~>2?cdk>oi@AsL_mEEv3EX`%EpZPV5n9LNjsSa;*v! zz71R0J)Znof0Msv{dFGK-@z^3>3ryH`$iLQNn5a8OG>x2JS1ann%XttSh9dg1rE0Zmgz^uK14QD07fQBYbp#V%U! zI38~~xZwnQQfl?BXqb0cN?k)*P4Bu)YA1KpgAg~=bSAGXF8%z4sW=o)FZqCw-M$0y zQ+Ymb-VZBTTf^g8n`j%!vOV-$4?e|Jyb!Eff>cR-Khx94U&iE)RD z?G_ie?NB|3UIg!n;W8Kc(<^0kmk-Q4Zkv|g@rJ+YR&74QrM)~+%uO!qS`$)pD3N;J zX{)~v`pFlY$nm+X2K|>y=|}0iwLj$~eaWTD)^?C1GQ)Qthz$cQ(how^{=;7h^gSew zA6I?eWq%%9$3`{0F#ttX4ocW#801rN z+u_uWBB>&VR%M9UvlqksYI5gJFYPgP{`s5r4@)!t+0NxHxZ3W((pu#{eunp|tFo3a z-^)32+6ZcPer}Vxyi^`xfcW&W+oT77X}^hYT7&8_o7)*I&vnh5XMu~)A%x$`K7}$7VW=`snx8l<>PJY+XHF9TO>QB7x6~R?Y*lb5D0lx*KON z{t0iKIH2t?!LKis|9Bg<8uw5gVaDje<LM7wb zt$LM+o31RVmzGV+--N+AT1ZX(q~El01RQxrtp0jsL3AM-)^Gl-IQ!xhcU0YR%`*sU zxyQ6B(0Tl5J}HT-n4!6P=Ggf&@4MXU&x8#8I1w3M#Y+x(C;Tw^X(jdb_nxy5{TnSLOD{_L6+t=nn!() z(eafX+p9jO#%eh?am8u{9GUG)Lp6kBheeMX7f}{^oDsxqPbK zHMaj^bL-}ai3+Rb)IQ{?u+T=2&0^e7xzPb)ozWv^=I>lg=%4r>=OiilK@@u% zT@9{}VVIFH&_)B_8N2L_?D&u;)84v|5UW220G|Ph0Fy5{S5>wLj8&22Nf*%TFJ_Sa!8qjGa6?=m8JeUU%5vc4C{XoyiXs zy(!tw$OU6!RRwSIpTTXx?^onEY2}wa9E%1#{kREsCwA9hdY{${Gj?z1()v%|Ry!@q zIO3=xz}hOE`jLLeSf+QgrOfcj6uNS*rF22L>fvwj)4eDD(yQ*;DET5vOk7e0_)SAIE-+8sYMN8jesh*mDW$Chi+ysWq5j>kPwJaTcAZo&Kh{MUi@3%z@&8U&Kv03zN&Y6e%g%%kR!DEs%a55P!EW z=K((iqY(6GGi3CDR{iShIGo(!TjU*L^=HRXYKb_)x;O4zDs59rrq;a}^^t9&(9Xy6 zh`aE}wNvj;SiJl#S9#PJl05PBqKgdwn(1|qsz;#?0u`^DA-OfD51F~l6#Y!o@@W2L z;%c;AQLEvt?;I%){bvoGXLue9n~&5v(2x_$%zD)O z7I=q6>!G~KgA_o&C~z@oZ<$-fjrN1QB(p{j#@7MGVtiILiUJ~wW90u$XkJ(##tZ-o zr~^)9XwqUxX4W~vF<3ww#^Ve$G`m4ylP4Hg%>1ZE(AM zC?n(l%0LmHHk^e8@5-PiPKVMmQB@FSG@8i(E=Fr*(3aTRp`n05qH9{S+`UjBT^fW0 z0aQ1_{|Vv|1jw24*?@%022vR++I$4z2zo5-__~x#B1#d&O8-|sLDeN)1o|EwZ~@@5fa)`1@_)r5XxIZ8X<^GE zHIP%F18jtf6s{3YJ zHdeb$zN*b`U=(CLTlv*N!286Yw(xm8AbE|l{uetr?XAAv3^v6*4`@H=8gCawE4d$7 zXwv~*8;A;koog5t+A<50$teJGG3KZPkSaEY3BYNYB8>}(_${3Nz@$>wpY4iqhiQiia``qd^(qt3ufT5(lar@ac*;0`PEXhPr-W7AS(6 z%Jd8haM}^5MlC=^0v5?U07wo3pFi31^M5IWB!P$Tg; zVHQRS1r)TFQGej@gLPekO2Y+)dT<9283##i7$eA_7)~6>)2#!CaIdC@I_lP7F&IFM zb^=|Dc~ZhrL6o66DIb;*1c29mvH?-bVboOcjPa2T)zCr9YRF(3>NoO6m96kY+Er3kR-XqQktu#cj^ZE~Z6N(L4n zAY+uo5jcYy-4;(EuWLwvb_Mc?GSc9rR#QBU|`@ zz3mHNOuF7o5dQWC+#>Eckl+N13WRjSFat2qfNR}OB6_=ltC$-Flqif&N*K8GAP3;! z9Ykh`xPe|SBw!N;SZT(f1GsDH23ZbzfJ6dL-=NJU&=Ca=7iFj+&jLCq7F^;WE6=W0 z0hs~jD(I)hje-^G0l^IA;-~o?1+pG zl0l^ekd#rt{|TP!+*fH}rHH?>h1L+lTRfd~t3zwI{I3*l+iCwV^xwS=k{kAHFm2_H zlgcjYY*Lk+C9N-!xc?-Diio}vq}y)0Zu9i{{bZ-Bg?X??SWQeESK;x(`{pN4-%m65 zMEukY)V&$1y}ora+C;0##{2yDM;f}(pWnSKHP(S&k41(*+sL~+oFT!}--1NDu&$5u zpP1ne1(Zv6+Dj%NcZ@uxB@OAmfAo{*ZC}+mC#bpi{)eH@=UkS98hP;5v8CaCRh|}y z(3X9VG4U0>+lb=!^tC226-G~vBls?1zFS|*s%A3Wx-Nz%eB5N^E zhx^)=h%~9_Bvp!4hizd2#+&kwImO)X9N2xsJySBMfA*B7qIfQ)A#l4m`s*t3SA_yr zk#PsbI>2LVXm}ur8NAh=X?7`M66*qt?4NLMq!Q)Md#N~g+|?@@D0<6yf9(;g<-wKb zaYiRecxltUtP>H<)%%-W3U0j%)z#G5C$;l>TKUbcb_hq4@M~P+%V=`WEdo?w2?92 zVi$va4$n#pZfkSjhr%!eAC=2Klw;2S-s0|8u)cRkO}t(niAkxXdq=qxH6nXCFT{&< zP8@R~s!%FVMt^Dyhtz(K-mhWa?N+k$_i$%=9z#PK;UAmWRpq*%mod!`dzeOpxWknK z)7Dbf9a7(T-^46-pX?^B?3O}sPyKu2JUa%zvrhU>x{ycd!j0+>errRWln}2`u%Njmtn5a z!aqgh^D=1j?@1MRjJ=j!;#$n!Ef8lYZazN;Wpju9<43yZ)hxK{&@i{=y%}RiZNe4B zN7C<7?N00vRo;ZMR&N>Zak;>KIF^Agjl8t2;h|Mwk$sK^foM{t9V9!fw;xS%6Z{C6 z9JHTd?x6}VJvAyDzI5edAdC1-+Em+ZDn?8!LRm64t8S=4d8xyQhP#S?x1Xthg9sJY zGwQKY=RJHj2ZCVaNaCE-GCmMWo?^N z``6Wy@ivY)_D@1lVDW9!K$4M|`8yo5H*oURx{5Xin8UR?_c0VfL(PC1-RB)U-k*CV&9J@|)uKJlgO@+=d$7VInR6d@m|>OUbtg9D zq*qxveOegjw&7Var8$x&hsP`qX#H-J!TszZc9Ir>d&6$-Vt7sObErq=h?22Op}Pao z9DAhDC?&S-;*CCpiYTSrY*C$m%qZl;YO|FGfee|*Qy@EHYd^RR6=+CW7WvPYm?CIIfBDCzoh&rVw|mWjlkC>($hjGMivesQwt$|V(FQ>}Fv+)mes z!`M4h!uPa+U%{;}d%oS!y|mdG7v#7v_xtl`g_TjumM6_Ne?@)%eAb!RruuH`W1C}* z4=oas-dARJnp%11RrXA~gZiqyIr!{k=sBCa|EeTe1N(PZuGv268Z=H8*iv*jVlLtf z%H<~>l~R(;=%GW`wXQm`a!mJey-8(<0t1M|J+<{c#8<-g~?4xu)f11*!hViI*WULDCAD$bi4MMyEgjlF>f0fm=H|=M=e1r(@jwz*OjW4hjl>@}t`;-&0TxL1rSERX6o>^VHq9P0b$o5HO({%mO3 z^Q>{mudb5AE&7)8mn9x??TdXLLX17%uI0Zcc@JOj_{1td7SrR-%M&k9-78WxmvB(L z98_Ty5vd6!Y^ZYnJW}A}mQh0dvf^a7CH<_;M-T0VQ&&s0_e`efc7oP;()JyC-l{@% ztfB72y_Y=sX6fC#B+2$Rnk^Ie?wMH{p&b^?6M}*>T|3O6vM(@}*Gr}B%)MjGl4)j@ zWcM>2iI>zRg>!wGFzKYb#_F(N4O~TX$AdPfuL7 z^tVsRQmffBYaH~@^}c-Yg-_P|a-Ce#o#tY9=f7&t=5N_tD_DAPB^#l4#C`7AC4R#f z1vUi~Zoj(!cE!oKKsI|zveD8)N-x_eVmbQagn|qbcIsALBP%cZ0>nOh&{Sn@e9Ls@ z&EjZFzbA8MI9JvKbZTcy1TVeea(Y;}vWdF)U%MP-65g1m`Pw7_C%-Zkn)yDV7wDZ60j&@qLq@7(s8onAEE_gg#^3Nm+AmWPWWQ|r3$=?*zx^i1>r;+EaM(>-EFLJ&Eg|1wlCp#NTG*pA!>*CX zJM=$s-t4twos{?7F89%nm-5ZgalK+Qk(Tmoa^?EfJ>U8+oDVA=pUjot!?=t1FwoiW)FE_sV!ooUke?&mtxyZo!hOHWu0ak-(S!9v zze{fa9VS{dCM5ZtiPf^wdIt2Tlgs*^vu&ADgBBzpKl+Yn|IjGF!7&Q+iIY!sIvLU zG4Cd8?~ITGAVnl!T#D|F+wGm%=_GQsxh!?q~V}#@AFJkuZ{i{xLp?{i*kcYUapSu zV<4&c%6l&|W1CUwQF1RCfbrgZ;2Q(^dN8L!@yMl`&*6_O*27@ldv8}jAFP>_{Jnzg z6-zUKz@Lbu{cY!D6qf!i?!FNnf2jV?=-WMQXFbB2hr*(zCN3o6gJhTAD?h(+OHvtd9wCR8;xTP}?ozi)9lOaAN8 zFMmBuivlz0pBCY$kLF!ZFG^fGrpc3FCBC%~R;ILkAb*tGx!x!#N1W51|0AWR8lAlJ zv6jb= z8_6GT{@cDy8!c-Q{1-C(pnrs3{|Z&hjl+eOJstn98vY%?yJCmFyPj85zkJ3OLF>05 z|CzXBm-1^*hk8{G<)*&ojfXISyB}HCKb7&S?PfHO4 zCs1cis{h9%1LUcmo>v%-3a~2vhZGnhk=-DaYZw*;Y%Vwh6iqnCT{YqTPb&cg$N{bq zstxVk9A!uV8Ab524IEb(IBnKkRhR`br9e{zza*r#uxrSp{Q=uSv=)d6yITIo;<2(-{FcX}UQfiZ#rTn~_y?Fc(g27zf(9xT_8%q9aDB!|ST20+bX0R0bLjXM|{~J z$gPKRAg~lbLex={RE-?SSHfc9xIR!#$E?NxG%r8>|9I;EJ%#*gJMKV+AS_HbPf{4z zGR7uSfHJD%Vh)m;?ylmWK#bcCSm`|5>?nZb42)$^#wbe>*rwV*%uM7!1l~Q|Tz>{I zW{r|FiEjVPI(cXZilc5l(TnV+V~FymbOeElNS?QgVJHyyh(wAPplt}0p`gProdNng zLDm=<2$POa=xj&i{p)0z(RjN8^Ec zln5Xnx?n)bd_^|}`D$l8T|lMI0mHPWhz~qd=?pId=<4KvUQ8u82BW|ej%I?Gcqk_i ztp^939abjI5D;%12pMEXW(FIpg97&irI4N>>_3i1@v6g+0EE%f9A*SObpMNCx*R_c zes2TJoqZZW#_<9@ks3-!qHs1S$N-4XP`(0yC>H?4H;9)SbULE31P3I8QcVa=lNW@6 z)H;&I6FE@wkc@r?_2gKnQzW2dp`)qA$QUw*!QTb+Q=*pmy4Txz`;<<^r4& z95)C$Tm`Q#Ns6dV#enO?9q>ZwfuQm#qMg$bR1cRL5Uc{~5RWMmP)V+ELUeP2H9f03 zh{+wqC{}T*TBsvIEV%lJG)!Q`T^lj-9!LkiNx)gD0pN2`=c&tPy8-@ODa88WCt2f3sD#%0hlwvC{zUkAu9#di2$INAp)J5woeKz0EKN~08azc@ zNX%C7&j}vDr|f}x2s&OV-V{7IAtNwP+MO^^0l4!S|67#@*rfD)C3Kz=OD6~o)Zo@? z4e=3#6PCyZ+9lX^VD2fK3m9L5FS~C~@Ue z+HH|6G_$G@=;{1CB5_WyVra1Rw58s&>Gj9MXAUM}^eN|G#$@RUNK}LNdEe zusT>c(guM|-YzT50xoF^$jsw$T}rzD>xHsbq<_u-zHU(2ApfuK-=Tkwt%|AhYA4x} z-?}&YqZ48>U)AA0q%O9ItPz#rdn(mc+UvKM)BEZ!nUQ(AM-0w~^lr8Gm$b&EL~qv2 z*_qkFDZ|3FHV2eX$<6&XIY#~m6MJ%rOiqy{g?kFCR>KWq_ctdqJo2-0GN6ilmMKfV zBMalKDSvzYWoXFy+#|O(D0JHP+FFBH!?XPlgmZT7M-pwhW5Qdl4y}Lm4G!3t>vyR` z9&9pOv-osZe`pWeEyaI zt3Tq%_z*Vte3^k$_kcvo6Wx+o^G=1Gg%SsWhwIR%3f5cn-PNeX=!^F2B^7ClQ+sA_{Jw&CqJoV)zaxh|6ztQCW5^bh@{P7zjNh@yE z=^mQ)zYb``##0>jE^cnMvI`N3&+)kWgN#O#Swy!}$*Fgzr9FDp*Yf2mZRfsJj(*ethLPy#ebh`=d14$6_wtiHdT&RL;q~T=CF%M?-i!`Q32t@2BNc zkI!Yk>WevR^{?ttvi+U+mxmX(592QERG@EOaDp7B=b0-cD>Mg~=X6)QM)gG`zcGRd z>e8Dh1AZN>@*PrK{Fv9ebxifN(*Nr z2Wv5l(eAQPwMFe$4}5-W^;6}y(^;`O)(0rsepA=x#Ei3hZJUmKlte$cc>Bu_{Wq}_ zj+YmDb@h}gwaL}X*&hFu5S)K z{>4f6Ag+$V-E;LJPFh~xKWhyuzNlH(Abkv>n^BIl_~S+ z2HUIiFY*dr zi#8sX7A6&2;qM;OZct4y-AJA!Z6%}PEoB_du#m0R&c`I{pM29)cUeXFk_kBm!9SUo z7bJJyZf8ZoPfPyXQ5o@Z^V_2bI&&@~)o$#wU)P5H*fb()H$&#EjlEi3Hp?0+$?7mO zgkCNyhX}UGNIdK%WH*wUNdvS!_b1Lmf808~V`nxQX?o%9rS?0zu1ap7EceRDf01*V zGeIh3=LsW>m>p+UF|YT3plr1f`0ZaP3#%4j6!QF@wZBRY3KHpjmb~v9N|RSt2NLn_YgYmr1&k;TJMOi71# zg{$<(LnBYu%8o~fbdb$@!ILj^X;}g_qoW;)GKV%}_Iab;G8O}U%bd!)4pH|PWgV4S zIzwlnWa-(yk-wdn%AE+7PMCS07Z{e2O192>AMvetdoK;7=5H&lgi}YiW=D9u#TE4n67f~-zTn`-0>MqE zu={De)R6RHmwmhcOubjI{eodl3h5472TVlxx|ggKD^U z8unjQJt22Bi_H$|eV9&tdkg8@sMK`bS~vP8GpIv-VrZLqMV5$~)VUjR+f~W27ND!E z9+z15Jv)YGJGjU_OKsNt`we>R)Z9VkrbL3KmfudS+0fD@F}OZ@7Ls?2-2EN0btLaN zXHflZ{Jt|eP4yeF>S?KS+dQBJnVjE6ygyrA{`7A;t6z-9k8GndW<*jeIlnZd(H3>Mx5zW+%xIMOP*zUA=Y)CZ9qD#G@I%eWHK{(BD6 zH|gmC`;Aqcl-V#iH}*@SuJYm70HB z8Atnj|Kzlc52en&ZYv7ZUTs!zzOfYWiZFlX>tNy$`>EX@mZCHfKOL`&QSb9wx1J#V z42T_9G%{MTNr#o2)xta&N&eb{_5j>jCW(mFSb$ZI7iQ?ae7AMa~hbm zvA0<{dbPEW4?l++o%{lq&3q8cK2w<8yJw+*o<)sYcvJjP(%atd@6@-)+K(R^{v7{% z?A(>_2YoKwuRVS0v+s_P{oioctdaUt$0_p?mWzVx4PUO8*4-zzsW;w`%l?^yCQGrp zO`PqPE=|lz-AU{3|7Co_u~U!@--P>vkhkI=g}%Bvy~N_G!r8i$`*o?a%J2WC>%v;>YmI}!uyI^f+YkqN(%Zax2w6i+1XXq;(7AKlf<8i_0c!FX2?7D zRbuOKZWUj#A{YO}rgp*=+lHWb<1MZNVNBbrYq~3`wJsc_&CCo;KzH2RcXVJ_F6*(| zm$}5>rBw}OvsEz`rS`|WWw+3hrVsBD=(H|B! zwUL#S#o=?-8*w%ATX6~3Rg?Z&W`#-glCg6z@r$DoV;EWO7%sEBE3OM?;H^5IeUjkF zi!ysq{i*hS*AT5%T2@*j$}#)T-#fCRb~bRwdo0=8TvWNRq%LP_-NM!2^~++E2(`<> zy^W7vd#z=^%v<)EBwQITOVZc8<`i^9=PRz9dEUf$r1;@j++Mmjj6hf#Jrg=+h^(T? z9&A`WPw|(-NG`P*8!JUJrsEU@r2}^GcHdX3J_$lCAN*e&-tg-2;>|IIkhri!% zUrHkQru}}hkm4Ng!_iQ!X}-UW4s9w8Ulxm#iMq1f{1tk(sHIn9T7lIY1rN~-;$H0g z5WXbAxs>l^-LX$K$MBb|j-2bZVaJ;7xEv%Ro42@m4q_`?lzPx;z;Fh}xwkci^G$D4 zWr%sm(PPP}BrB{|uZOdHocy)LFSnDd&-tGkuzHcSyR>+$O;PD^t?PhchP|Qb)>J|F zo(oa2XEEu7l_-qFd`^$H^VVmVJ>-?*3Tr~8%A=MM4+{_{tx#O~o;~q@E*IKsKKdim zO@Bf#&ts&#D|{i2N4hz4iY+Gfw|vn}yHzGyyG75uzb$3AZs$tL)r`yb2MjR7`#xKH ze8#*3MXBo>+HIEaNTqc33LCy8ol21s6<=@rVmcMd)y1vZV0iu_8ZW{cZbhg8T8f+A-Ywc;`okaz(dhmDVrw!VaD1YyF5u zD%^i|p`|L^BY6Y%SH_&%wcPJBQ9@6xl29XQ?EG**l)i?3s+i1sdAT7OTd8n&xu0e~ z4%lf5H%fFpj8cD0_eXtra>DjQ-0{uFuNXq!NB-Wm)S?C1^+b!Y@`^Y6=e$!EH4(LR zZS?Ge%Efo|7u-7@J;4k3=tK3qy_2S&U=AJGF8|bv7%%%J|J3>Qx9N%7??5c~2D}YD z+}Fd3Jj(P?$k}*tcY#l-NEq{xbHC|(uwL!LOq4I1YohuOm94y?=C(rCF-F=k4{yY= z;~szeUze+!W?A^`Sd3-8kD0mJs#kCIm{%?S^KG$@@wB}8rVQzpmA|UviaSmB=eLfX z__%p`JzWupXGWo*0|Qp)x*6W&*Jg zC!r~^uf4j>&ZDFKAu@AG82ry#oN0@LH6dBoNOyAL{GR9N%sx>sFyc>*+NJM+9(ZJD zLY9A$#7I)X?2ul!_x^0<(Cyjq|W_{Y~0nHLFxmVvw2d-xEgz+Bh_XPVSU(4q1oGO(qc5{}+NLj9~*`(Np zaPjY>TOI!>G*5miB`4g73yRs>En|1*tY`eK)3?K&wd!`a?hJk|)#;?dBZQ5bFGg3XNtZm!@z@`kLwqk@ z*j+FRBP>b!_70*&l47xm#)&UAKm8gTP^XLa^?@r=LeCc z;rNfRua(#Js8NP~VI({{s3rQ_qScSmiMo_KcK^^96X!a!Hh`~-|GGA~-#vlW4dxgO z^fc?9EPQ(?Ov57I_@=uJ4GmD-OK;VCe6MINk%^2GWc&PZyMFa^19@S!M^H0)MI(}- zm2E7}ybpsvP0DbnJ>NF%7*9sy-sI&8tDo4HwA^$baAM=#tI6)o=;H-L;Xy_M%j3$V zg=V7ymje#ld!mC*5Y}|`o=|QZ@y0S4?#p}BE16du2=Y&hgvI$WSoI~}m&AOsmoljy zrADeBy{=yHj51St!spg;v-i`!B_`R^jNI)!iU{8GODDsD8)KoD9(SD4R?i%; zLr!Z2F6R|^W?J7u?fu*OjZN;SFSaKEKu6whj zfumA7T_~zj%K&L;LlbWl7O6|(IZDx}Agk`}SuYpp3K3D8gKQ!Tuu51IRwr--h)D*d zg2-#)2pR&1fQ)!nNE-Oc_HnAkrRBRWLDCfdccg+|;lWIASrH251-@|$5@Br&@E}AI znWdqqmJ;TP*RugGGCL*;tp^D(%7ak*Q%tZ_juvpN!UUuK*-B7g0!>iu16**B8CLht zRzMO^AaQC4oT{^?{|hy2Kyv|3Qy5KzWHyJxQbh*VsiA5qSyEKlt~8>+i0-f2*V>%G zCISqSU1kO{*Pt52(RES{>X_VmATQz!y)+<~#8y!#T)%xCfEg20wnGf;1x;#YSs=Ac z1J{|81+01^7nCOW+$e*4N=R)^Zpx&-R0_h4CaS}`7V?td^x_}!X z4YxBi5;fsd%<=Bty!L`H`z#uuKViVFQPdD$0Acs`S`?|qP2J$OOOy$d>xIrl!R@nT zMY@@GE+khm6~OwDG|;dKaxMTaPMSZ!#{g3&5y($ekOsEhxJg_n3`poip=E`RihP51 zPBnF*O}B-|XOpxAk+Oz>$!&@ZP)0&C1cs6A00h%)LLud*8|sokJX#x2QLWy*HWtEt7D&0swyd23(Gqy zn^HBKE}K?k><|mpeRGKn4%ZD9nrf?3RQaTUOe(qUNM3NY3=^7{fmD4V`X&5RVLiRJT&D)JK#_8iHUS-hzf~X4I3QP5y z)fW3qiwL!z>Z=KDie3hVZ1;3VKSEYmAlsc_3*b2w8#gA$lGIXxhd573J`b9~05Sj0 zdI#D7SUO$-(q#WEZxqc)R@jQ7jd->UV&ny(cobb|E0&;PM>UL9?T*JQp=_jap?C~! z_!^7}3PW2s0HMWZK>{3Hz*^)RIJrb}IIZrWlmMg11N#ZO>~g~}Y#4ET0C;MFc1|L> zB=6Ib0)Ak?uo6U(U7-S72?Rw03Q8rR0x+7<-h=c$>y*f1S${mkKMgmC1RauyS}|#` z4P*#n^}-QqinL~cibjew005D+9tvp4qybIy^EXbr3>@<$w=$)D(bMSpVpra?8{8hz>%fhm4*v22a)+h z@_FKspsQ010TxDN2P{Qe10oU*Uje1H@NRPrIVwk%%4_u&n0N*8XQc_OjB+ZK>IFeS zn+{8f`Z!f6+AxQdFNIbD(0D1bqt5cC#2d6a~fdbfSJD~KM;6afAc*gN-;pF~I z5p|K^?4(4aQVp`IN#K!c0QgZXTBKT6HjE}PbO&kmfX!mkDBUb^<#3>$+Jp8xC7utTH`>l?Rkc)Q{0hVL7GZ5V2;3T&v`%-)j! zaw9z=#iVM$(Pg#js`SVXQ+eBO_7Uq?jUJQ31zT@yzrD2?DxEh(pK|OhpnKVQyuTox zRywg2iRydXuv>1N;!Bj`aH3jHm5y$_Jo2je+V_BmxVt~U6fH+GhoAYxe7dWq)nt|N zk5+5Caq+@WS40aj_Ah^ld1yi*a9MjXaKe0&^fTTgc7E@)!o7&MzVm@LRc|XWAKG;i z;s}&W-5;ml6GsqJC6wT5xV>)n)vE)P)6ZdGnosZPrXMEwPFCH>NWXu8ZIk)OY2mqo zT1K?%I#pXk;!XIigxRRSC|ym8iNb5mgx>Hk+8I+LalsTnA95z-hMk6~?}~LO6bE2p2XwaQLhjV)s zA*@>gUtxTX%~fK9m~`tQU5hjKD=&6E(6ilgVGy#62>+Pt^R5?(=YPD-uMotlpQ6`l z*S9be4lS>Ky;k#^nDluE^5q=RWf_@nfmqT2qlKySc5+j=Unc-{W@B zQP=2t>u_Mqrf=es8~l!J*ML>;DViSqc&FfLh<7ei?um! z=MA~NJK%Iz@+OHt{G`@gjtc6;#4C}4rnFMP5Ay7?y^}QFqOsiV*7udKtE$1_o2KX+ z%v2|CsTbX9{H(CK&LL9_trTVyv6eP6^|6?{zkgG0G#0((D?VJ9+HXu(F;!A=wLW^E zRj`G5tFvBHeYSPsX%T6As=kB`33mkfisoyat!bA`=sHy}(QBKM8}OauC@6X5S>oU1 zd&Xs-j@MtmR1AJg_BaftFlQmIn6*<$f9BZQ2m6-qS8d(uih>MQ11$GkdbrJ|^5Yu= zT9r`nvz3zapCrPU-aBGmvUyhP zW&JaF!b9lBEeY9!ROl1l)>KKANdq2l?EBCS@(TrHiAMWJ`SaX4E8)7T?6&=tA`b~} z1$=C>17Rt4veVx9;7Mm#g#X%+-z&;;!Q#Aqzq~+Pw6AZ=&5GLIhNJYzZZ>8K`dcpC zQ!Ll#{$(fTHp{3!Ut$U7T(;zWzjn=bf3%cCpzo-YSWLd1Ei!<2xh2Tpf^jovFDo^J z?}U3%1AAyv^y-D{2Ahh6{wv>|l?>$AdXL_5ElY$aA!!{aQC0El^x;3z2Zuft2OW5p zc;Su!-uf*u6}!6gNJ817!8H>;ek^t`al&>ff)kfrudt_owkuG5w`+oX(sQ{)@ixgq zr`={wNUPb$r*o1|tJB6Tw?_ViO*{9+c))N`6@z1v*(%o8hUNt{xwiV8gGMC=n&-q$YEw;XGMt-ZJcHa{h zsHMT58j*J2Mz<^&)nS@CZPyR06W1(H)e z-eGC$E=656#)&u7SLOf^#xhftKUY4=3{&xr(SJ8ikEn6*bkFiG{;|Lf z-Mek!Z%_noQg*47V%lI87-^b8U3i#^zUvF&*d8u;dS=_?lSlK?j~u)uAwsQ}w%_5O zza3QU47Gq1_`{jQbNm0Q<|!y249`@LJ%Q9HNqy@gZ)LiV6>cznGK=H(ORVtR&RqN9 z@(s;={UjfM^4$tXp>~ir!?^ooD>3jzg&>0TOlRBE{YMsYA(euwH>f{d#ko8yWD+G}6MP2p{C-ne z7E;iFs-u4ms$;kzt_s4TRFRrj;`%JT!+$n3X8CC+8{TKm0Zig!wJ zIcppqX{znS?@@zh%BrGC_uXwal>G2oO?zPadX2ZatWwoExQu*NZjIaFKLc$X9g5V4 zYN_NI8~kSbKh%S6v?mg3be0Eh=?6o*|Gkoow=TxK=+CPWJM@UZTs~3qsYuR%yb9knVgBW7gwf{f9IH`Qq{PGQF9re!)q$ zNl62j>h^rkz1$huSaE#(u9a~D52et0`@zMYO|IQmR@XN~b)38kdGMQqI9EtMQ??|2 zsC$dlc^t&=&HbZrL)^LITFOqQ%6{PIwAKjwk%j}Eh|kiN{j zWM0~nmwA}i)ZXt?o;1pPMN9fND9I(8ZrTT9pgT}+w)&$2Qe+SDziww;ol5= zh7munauVfQqt4#Ywv_!|%D?$djC^vuaC<|Npu7Q+Xp0N4+y>dPZ3X_|$f9I|((l8F z(aXonJZL&kSm&Dl7-%|*f&*s@RH}mZsn|z%3`l05)S(yHEg2hmCcTmU9n9*mV>$ld z4!^-XrLt1)riX^LptGLUv_In4{4C?7UJ(1ZD??((Gv%X%(aQXI>14GUULIFg5z^JM zH6hJU6>iA%j$c6_)H!Ro$tJGE+PSDGuJzrf+wI zYBsWeMgjbuMt{KO@`q=nO>kd8FZ5AM@be8B*d2DXwkY zq`D`oJ}1n&hyV3%@I=NPw(j0&uV>p_&~!N@tPyhgn5?@&@8-jbX;wD7jtAx28W zV#t$P6Yca$o59T~5reCmD$z5^Q%g}9ir1-*>C=t5Vl5+m8X6!s9Ge7IeP=gD{vj(b+m91Ky6*A&3 zC3|N_p0s%t%XiNDBkh%5(J|S_%RB2V>FLojv57KsWRvbH`BSm}@`p;2uYPR3>hU{_ zWWv9O&lUqeQgxw@Mfb|L)+@QL#5J3flU$)UCDPLf_Cmpv)$dn{m&HwZC-p4Gp3}ry z4mx^B#--e>LXF)?RXVwNCc~hiSGqTIeWEPFo#XD{2`|(7)T)cV4wb?uYF}nxF4HHY zd7Y%!&55&Tn%~&Lgfr}yU1?U?83kwktz_-$UVi%eCgwkm&OM&#{g30-p3*#YKNvDKsq$=Q57|6<)!R%bOm?^dEDZIb^@ zOuPGIWyiPmF$d3Jh+75I%l%t2yo(TvH@eFjTF!M38sRU5T$}sR=k~7c^P-}CvWGsd za_fmm)0?-tP<^GbA!qKM1p>6`F~ZKAHDWcl^9KFusVr|9x4BhoLBgQlQ~pI$dfc8p z3&`i0ODj?u{VW_D?;iJFfKq%xFwN`~MZC%-l&0p}6RNA$YFMK+O5Hepj0jFZn`VD6 z*IWJctbL>arN;7Q6}o)O-A2Z5NQ&Irp^(K>yDmiPXSY|XS1W8(JvLuMnxxv^84o#i zDIHO%lj0V>-aVg{WA;`xBI`lU0wW8GQ>xj?X4io1h0pRgihFzzD+ct{Dg(mz<6BCf z2NV{4%v2_Y_YZq|s-j1WTlCiK%e^q!m^N#cb7fFTAF&{SeuILK^BG!YR9d^`+X zA21KFWJRkM1yA)T)k3(_p*l;cOQ1*r)!4>#i*{Eiu)f3|#}F9}$fdn9Pj@9l1j929 zW+Km!pN9$5LL>v563U9{Txbefs&hp!@8G`~4}&HK16GnDg{C%?Yc<)Rzvc(2 zDUAIQz96~>pHVOks;m?=I;E2wNT5=rP;L|!>9UnEY%J`T;UndPU#LUC6>zy1jW8jQ zSZ!2?zZ9Dy^8+*`LZd^86GG{-O4Nx5-UBq01L<{+B1mY}+Mv)K(}|M_9wPMszRDSj z0=1C=k;vj1A#4n#0`O3a5EdQKeQi@hLPMJYd5WpPe87oe;mr39sVw5mGO8*kGAs3n(1WxC5mzR=Glqt))8QprGiR2Gtfpa@Ta#M2iIOax|6 zbYNTtK4s7))X$It0&WN=At&q*2hr)DT~mkX!`6W?k;aiK;S)DfQ`!~L$Z!iVm|P~Y zgCY?bq=hxl_@mG^YIbD^EO0hSF#8Sx=@b2PrXfYfVzUt-2g4P3>bDx_*}%?UD7QaI z67&g5nJ6~f8VytK+Du?e#4|xte^BCwL5XIVp?IQD#F0k_;>k)LJH3-gL-SbB=aCQS z&f53yfY6F5(w=>A%Q~&q=ul0?6`^&xa^pONh5Vo? z+z7!?r!P@f7nhNz*}>-j(+C>kaEd|*A(29@0g+)1PY*}Q zsfz|76RXr&6~e_JRpk$H%}70_h|R`sGBChk;I@&Wt4}92heB;T zJf1FKhHLrZeZfei32&)NnW(ZLHzf+bE>sK|dbc)kFPUsNCjB9w)PNuc2h(h*z8*XX zjHUK*eozb-rL%EdF_TUxHI_&P3=$^gvnK!?tbq#BreG+enGA)8sIu;$r?pydu5l$i zky^=GdUpN>Sn$t-vO4=@eDz9KE;VH4YD5>Y*W((L84m(H4=)iJAY*5BDn zWTMoKOK;exoas&aj8z=HZ8j%OxLfUpke8KSWvBfsJm&SM3VPZfMH4C`Js~IOoy(;z z9YxgM>&({ZbU{h>EiX;Z(cAB%JL5AxDHoyD($FEcDDr(DtNWR|G+y2gWaun!+TFlU znw@a9kYmH;*|?KgoHbcc|N~U zqYvNM?rN%KZh8?=`OJo^(b4h^CkCxb!dN50D(R~}T^oH)8F@qh`u+8K_PA!T zMsM@&oQ*2k9ij}DYu&Q%>7#`kMq<7`ju7x;zB`%w%wDkZ%Rag&jni|=>|BL*a9Q!U z!;K5mmdvj$VglVRR`bQ)D_1VI9I-pOcuu+d9&g{JiDr*hx0qjzeYx&Rlc)8uJ*SDjkRBxZ8h^{jh=HX=00+*Q{(P* zZrCLY`FhUje3xN4`}TilFN8AhjTFSWsFh$49euy1Y8}J;xl)sl(Qyyo@%y%1_HDOM z$hwCM7&xN0o}Sjc)lsYXhv-gLYI)t^FMSqPq}FiPli540?Q~1VB+Rv$>3959-Bb^n zdT&@2R`T$&+ehYr!SXMQ-}IJozMoy;p5oPcxG$q$it}jg3{$xjbr<%2Y_tBEP(3PioPl!7=G4^2SCES<@ zGcD@IwmUUfop;|`;*M|>T(9ku|5V;fv|nsx7Q@`aTgMok9rS{A>{7uF=R<+#TQ!y< zbN=ZsHuk((@h>nYw+inxwh( z{q(^=6BXjU%0p$4piM954JD?IJucmK@BMqdSD|}f%xy1@3+Gv1XM z_`VqDzn$iFQVSXbKjf*Xo`2|{;N8GYu2c_*?DLMo3O30@Q^c{G*VG2TE4WoC{pwZ7 ziz<;`$*(t`*s?oQBg{5{HPp)JpSrRRH}=Y#nG$J|?wbDJ{M3vexGYw$zS&pz;{n%) z<{(ec5qNH_8X~J4xZJP?W8dR7Z!bbuNBu@;AuFWZC)eXr#q)svvS z8eQueXli%xmriTuq>q^F;m1VsWx~6e8%K+VKCcL~>A>7yRu))^j+INdTklBTJx6-# z+Q^?g5_bL8P^zNDSHlmp*fC2l_bS6v(!-qE`%v+0|AXw+)yJ$Y-dwU!x=emxH71bm zK6_SuV;xbQziHRCfSxgCxz4JMCT?TR4)+iWy}IYN7RP~tZsUFE*-bwzTuXx4ZaALr!>G5Q2i7CB$x|D3B@_JtD?1h$Z zQnu7AX<;}lxgg4FXn(lEY&*%N{G0Q1<+}~^muzq83A4RdhVF|G%U*L6mVPQFm{0E5 zczeq~F9^@onk}O)j~Ne2{_{&l*`Mz`{qxJ3lT%flBTeTM%C;EQM=U>UJVrlV*!$qk zM!Ti`=KSnc!@*S|=S6uD8L5J2H^qsiCK2n4cbQ=sCU57I`+lo_5Vho0S33pqB!fqo zeWpYBjZQg#6z5IY?EUa0^{gx{9$hEI|A;m>cpfQj@ea^kuW!{>VbEh1HD%_y$R{yG zKFm_~(r=l3UiNf$)Ac{I*Ecs)BtOd@rn*reIBJe##2M(p0*Ke2;z&hj-b z5=KK##oo$Y~8qq?67%^RcBF`8iqls?OzI%l?I+ zU;BKS@vBU?-$f(9I&A1h%sPg@#|rrgkYAVfOh$|awYEBXs|>_G?cG0pc;m?y?}}r( z!@Kl@MXj@)D!SWO`9vV1>raescKBKvyf1jn@bS6J$fI`)hC$?UlK0zJAF0Q9Jxi&}O=zsx?3Mll`_8v{%v<8Q{(Hmd zvSZUN+H3VfbxE%;Q!KqdtTsDr9bn@ZJa-X=fB%%rI%H(cOMaqm+r1_0JkPo9^uP9C zt)*K?-9h)O_AI(+$&+Bg=yaatRZF=wuhVGP7y9?4hzwEl?FIcu&&9lU8((euZnE!U zS(-HhiAtV^GYzbJF4yz9vO8Zi3(x3gPPcpwUtq?^ZUqhRs@e>o6ibeaAT}F1=uFa}AllNNtHVWf^**qTerp4E~-SYI&T?=>a`i=8s z{N$wg@zGrdIfqMU9Rh zoA;ic)@(4_6902LU8&P2{-_Qb!oOydpK;PmxS#$o-q z@!)!^Gk3E~w|)9ZAKcn?NOEz}lLK>7dr&P~bt_FS`MGYjypyK9`gzOW%uR20W88zo z*Q{)@C_1r)Yu@?%?K`td?-sWN_xvLZc95R*U6OC`Hd*t5*tYAac$IIJ$jrU2wy`9? zWeYXyp{qg>baa4Mull8>b&Pk8vURJEMHhx?bj5wz+dnTy$5K9jV2PnMeuwRZ%Q=So z3LO2>$Fya(wicKnbt5B5d)f!;H{4R);sXx16`lV-1`wN}!IjWAO+wbA19c13Mw?jE+ zKuz=TotiJv_9gw1auY#Iz2+e4PUG6f+{o%b6^rww-&ph1Bpd409?E{w4;@h0=hF)E zMm#TR;OkzDdcCQ9W&TnsS~;Z>*}0&1Wu6)3$b}#1J2-d!HN|=Q)rA(_YLC&26nT!v zP<>_<56ExxU(*fr91TjIqEw=a_L~u2Gi{8nxsdlKtJ);|Okr-0JSYjQ;zfBR=lsxx8begoYi4I|V&{0)z;Hz%4r4zg2sJrGluF4_4UZH{NS zVsvr6l~h6X(xad9=ix3js6W4SFqRO>Gd_gU+` zX@#Z614Nm84!T0~#i6P?8OgZ1`I!g#{m<@|N2XrnJrfhP-^G^6ipFjpuUJ)Uy0^`p zxc2Mc@x8u#pDa?(3~0D;dn#C-P~crf@n+kjL-w~$$=zD!a4g;hNjBf&RHon4uMWnC z^{`^fQeu{9#B4g4>b|$I<|4PyYUmK5KVH{%wqU{Me7@Lglh=g4z=?~HT;_}%eMLIh z^2iQl8Zf1CbPjsOgBW35EAnYbn&Hxb>ccMeW;>@9p{Uo%?H4}3xpTUt%-=urhy3Xy zoySupC>ekCj^V}YcKBB64U4CW7Be!JbcNWj&p+aa#y^cRJ@)52(;`}3MOR%)y}RFR z;@jvl=I2yP+#Y)F^^XJ70S~9Ke~uaH7xukZeVwsT3lS%cSd(e#qB9Yn9+bZBW`W4v z=xSfN*({#9qU!G1RKdL2%>;@6=Sy#t`5Ovjq}7|)!-bB2@cyypz64?ICX4Utl=IG8 z)jJ`BoGQ?WLB_gQEINL`c(_dNa?J&ap((L}&UYPNwM1Ald!tpJtaD6!wj;;!9kQxp#BWX!(O+mx=jh!f%ORtQziW?lshX{NE~jU_~Au`k55A z#npDok7#a}mGGaBb9WW%u^+y`w*oF5kEp=1iV zO+Lf~H?s(mj6gDYH^>4Gg+zulj?2T+$UTl?a;G%{<6TfGCg1}pGc#z&){3A?nIR&A z#>7%RwGHO;!Tg|H<4_$rQw~)mc1n~dB1NIZRr!Euq?kqx!d^t6I2|55?86zGkf$}I z8yw|MgtZSDA|XPR2s}bS{qz84Cf2)6Dg`eO&oG5iDWeF6>;aM&4zpeqmh6cD3Luy) zq6$ILVjPS#&`lTgwr9{V(NsDl6`tNuF9H|=pGFZjV1sRJ5b_xdPB@{*F4GW+0&g=* zl%^;(-ehDb0*4US4=avQRI#`n!M^IPCNPt20z4t)jK+B=zAqK;OTZ|=Cp(7XF&K!l@ObZ{kS3W#%w#fv zms!~)f%FRsu2ixFQmj5GZtA8h0lo>NWElTb_@T5=aIK)(c&t#t^d(kZbY~z9cw$g> z1BZdkogo0K(Sg#yN>fIIABZ9*%9&&`UxccZit74{qo-g#r{e1))fFV+e&E{Sl}t8l@09#}NtQa=BwjWg$rC zA*5<>508`DHNlb{`noB`DqzV0(jkk>N{5h^0Pbuy&%kKLQLWYknxG8$(?d3BrgU38_3d;RNR>Eg z!6tqAP%lKuEMY;pE~gzl?FumyLOH-32q51=w(a>ylK_|kTPCy#+P}Um#c#kS;Y!Hv##sC26Y1oj5 z$w+=+(b=FfxD$_SDguh5(g$uN_@8WOzCNjKok2K16}wOyG7bKj$&aF>9jxS61u`0x z=}6*eCWO%ip0N675~(2m$x~N=ZC&PxNCv!bFU0D>x-4TqBM>_&gKZHr19l*OD(&Gd zAdaSse7F)?C)S4<9#3p*FK7;hdf!AenTLT8x3-YV^rav%Y!1_-qtyf-ZUV$y@b7e@ zgl%9-VuP3-Fs_?aW^yv{_;`p=Gcqh+k6`FUiy*^pqxX=m4ZGu2K?=hMxwX zInEP$l?wWV2@aw-n4<&Vz=xeWjPglQ8k?m;{_KaxRTfc1ZjNX)3(%z!e;q}1U`${X zD1ksWq?^*)R}RsA9vkay(h8Ucu*6JjEhz_-ZE272|$l^c~4BZi_ zso>?S-9N21XbduWVI^djf_|I?>E70_);m0t$r?ARm_VQ!9Yf;+%LGWG`tmqWHknje zgfu`H!iVCafO7-}2_4LW4}j2_LYFcjwg<{`2Uj2pHu>(TA4jD zN?-V{@POKjNS73%X~85o*sv>6BOvwLluRt83hCbQP)y)&(!Fp>Kimm0pEQUV0kEv2 zP~=&_|H8BAfTZ3ctVstvRcuNj9Q5d|pFNWoB7D66-J7NL@58^Ae^Qy)g=M?q$eCH} zt{C+gswJ-`(_&TP{#$PPHjJCI)!EvT+C3iH8m)+S&+6QO-(8JbHO>>Q~XRd2Hj~YsPL~`>}%(ta9{^eeUG& zfm?)mzj9X#Y_E^hyP~@`EgV^e$k`i|(-X8~v5>t#{wG^?H!Y^s=%E_Xu;Qqfa-k*K zdF_bT@kjG3m^gK_6hd*^`u}QYTk9-df4Y~vD#>xxXPoLuyV)2O+e}MzIId>@2Q9sp z>$&@Brpq~JV}9PHGWC*mXD_|J|C_dI%X_wZ^6C=zGG^uT3a0#|LhgDo;zat6UiCGV zu|F*oH2=v?80m4 z#Dtft=3m^C^EcGo!ROIfYT6t3Esrn_FTEp6KIBwSf6OWm688mv-8rT%NZkHy5v0xM zN{BzJkYC-ODfTb0@VS(=G~fdl>)K*XUA}d2&bBjFt0wUMr>?A6o`K&W4Qh8Ba?JU$ zLX>+9jiQs<)9kEsy1yQd47s0)Qk{d`I1QK7bJ)7+&&l!7ADeyuhPsQ2UfegBwRXwA zuE7nTi$_1iY%rZT-dpS95;J=4)v1&XbrzZnYeIs{&xl`$+<$1|;-q<|qMr$j)rJ@o ziHlXrhr7cY&sV4>C2Mu1dDJ|S*1giv(y-V$wam0KA?(jUgW6Tsdqp=ikUAMZCa!HN zd!D~K`1*l7k&VTdrt;aVwwqMH>Uqduv>p;{D&Oyl`Q>wW+lf9Tay9k%k=i?f#_z-P z-q8?i_Npg2#T9>xJ|}NjLW=cLO;F$z(*uor9C}#syCUTSPtxO>M}-8s+N0I6mGRnh zienZ(_G8!Arcok;2JX6Kg=Bw;o_9H*@GzZ_)y)Vy49 zMHzhj?B6B3AFQa#+4-Qs`YNS6bKkF~tFn6?C#r6Ed}JE0Ku*_8_eJFTkKKNjwe7-v zyxYOgbt^LyGDqCsq%6qUzk~nl@4O^x2pjMcH+Rd zAJXwRh3DEs#y$|MN9Q}NE(#JhEHp`+olP)!-v1`>3-97t9^u zskd1Lt-mH#Wp*I#p&tmt-+Fpr|4%9V>v`W^WtzYI~ z(eIUzcIJ)Nxp!;&Zm2|B3U;?s&8al9_S9+7biEx=V_~w# z>D?akazfq z-ZFLfBke8KNJgaR;_sHpX;;WX{ZQsz``=_SFYNgFu51v`; z*WsO4qBZC6vy?S~u4{j5g{4sqebiO0zO-zLoM(My@pwnm1Iyz*btP}SUngmER@mFQ zIk>)&W7wjU-rR{F2%;?bT#>4fG*>0S_JUlv>y~Psl|dans{iP*vMK`gRmV=1Wyt4- zno%0bNp{$0>hlki5~=>W)1eGW9ey~=Lo={w(F@Ys{_JmE0qvq?c5^-q7)z2fPUK{( zrW!bIeAM$MbG=KFswnl1aI>W~XTc7H2`5sab0u7wL%sH(?xpaAQQScvpZyJk?C##f zUnnj%vHnU{i^yIaV267gd1K7F++%x5@7J-s9sBgucj)dpe&oR==N1PYd4Hgx?A@8r zj=#&F6E*LDh@ZVXYRW>mT_-7F(OrFZsS=+YW4c;$nPJmEGC9$d^myea3CvO#a~#pWkD%U^uek-1-|>#vE8SF^)j z?*Dd>=&r7u7g3s_{v6pgq24mMX#Zb3Q=W#Aj>^Pta*?XaKx5PCPtI88iMb_8wA$_% z;qtjlpLO24qh3gvRyjYGv4 znm`@EQg$sp#^~3M?=Vw&xaHB|8Wp5lulWs)UR1++qB?R_>IS;CT2{Qly~*GZpH(Xb zVSL#Vn;?&=hnA)qk)$f}UO^dc5h;=}7fs-p+cWgvR=K*pi;i$jvP8-a|3r z8IBj;MWw5BZanhfqBmvnxZ3b>di-7)S|Lf+y>R|Z(7CbJtuPV0Ztnrogle}C!+u{>#doyS>X?3HUg zm6XyG?JL3>;!=~I3&vMHcBG{JeN)SHrhR;4y=`A{n-=C(&HDoD@eL9ck1@@wOCrOm zzfAUb553fh`0uOUcj|lUZbXhW*mi4MPMCqpTlI>LUh`{4TMH@m0ixp?0R-MqmACdV zF+P05&de?8b3(l?1w^55aAJ|>L?8V=nz@CKz9sSf(ZZ8qJ0iN&{A1SsS@|=hqBW+6 zxxW2~7O72rLyXCy$h|#t8}3~9qNN_T>!;MUR$C@o(OuIVcW-iB6rx^|8hhLCOX|uP z6QTp@*tG>Id-^Ng6k%q2N3`ON8&%(=$7ITF&+4byKePCyF2~yYt;MTU*%o_ODxa zdFwTyjjrAWvE_kUxU)8oIc6((8=s2x=nXyz%%x!A*t=vk+Enil=_`DvcAhgo-)g+C- z^2_FHJICG2Sz8=4QynxmBgyp@qkj6VqSfBmWpgd`GRGj1U1Zn3`+l3_?)3v@p{ZC~ ziupRh)kj!!#4+p#vt0+%PaiGF)4AH7`gYHPBciMgAO7qorN6Syu)0v@=%zfw{BS%S{I z?JRMuzvH}L{L+}sdzZhtPaa-+lN|BkNY{)CIWp zTin?g+&WIFa4v36Dc!d5_UqW4@`bjAWg$t?uY~7G6lX1+==`RQ?>z`ZyjRv2w(RB~uGOf2~y;-6C)X*q-@KF4J398XJ#bhvP2hQKX-OMnOY!!i;J{)8y zYofdrjxyGT_nrGW7$6DD#H6l0#uZ&GS;u2<`COBgGi#RN6|-40L6=(;Nh0N=-Hd6z z7y}|2f~Fa=pR~7wECTg04;oc+Xv=u8xio1X)XT8xEP=Gg{@?}!ZWWbEAS)EUS(#yPDnW~uIUP^!RBF-7ShAo=W?{JDrqUj z1-&1`6rmuV>gWV@I#D5y4)S3`Q*n?Gx^0G9$N^t+N2ehl?*pYzLr-gKP^uf?#F#*P zGF-2c$76}`xaz1(gb@nGN{3t}+)?D-rU2;^iNs?Q$$cciZ&KJ8wof#KcyL>V1%7Hu zh9{s{IpGVTaAv6v`8lI!Cl0jX^kIOsV!&`?OcMpMMEpE=1_{cED5zS&g9VPIDu`ry zl@Om(ir_-b?KT?liAo>Hy4yN{{pw2&CX2xSjPPI+i5#YnU84&0wmjYC&~e2YQp5}; z6BBh>t;cwq6r4hGV?NT59~_FrQXzoD0z1Hw=BH-ok4Wh;0TeBKBmgLdQYTk1L~^_%2!I+em}nw79b#xkK^@g6 zIy$5Y=^=(@CiE&hDuEO!gDzqfH12#DfPtK8MTUov_<*Doad^pb1w~Y*L?{!JdoSWK zcE#u!W+XI>*=#b+(@0$i^KMLX9JDX1aA5h;<$#f=L;g7fL{3aT_^Cb@BT)j#%86iN zpK+0k4Ivn;i?%nRQ60tc^!S*LKtdhB3yR==`p!t~^}?O%?4e^UlY%`99gJXaG7Pl@ zy04>js2?b;uT@bBDKxTB`gwx^+;a#BA#*JS?>)j7MCGC?d>~2UH&aO_!Qlc5qDsC9 z=Z7IHMBr%-U@NI%{k^&icz+|M*kPMK` z1GKlG$85nR0R=?I zb{o~;aStiB#^cG`)b9HQ9->J3m30Jw%1TH;k$`NPr^{xsA!$Viiz!7qVO}YASD=I{ z6XS^x*&0$r^v|a)=z~roE9hXN06l6?1-5dir}{-eFq8Z04rX}6i8WFFMktMD4p(tS zGmbK8r|-;7LTaXZ2*phIenYMTgkma?+d>=*U0~y2p-cep-~wq6GbmlIrP#gdrsucmO<{m=tUa#ofSYIMU$*aMjn4jJBD1%hQcRrESo!?y-|e zX}%zw3Ppor-b&ib05%8xRl$;(EK@&}^zZM#pR*9N9A??f(*E~DCS=AfzT=#d-ze9qzl~`Bn%;Qp!J48!PO_oG zg{!DV!;kWASRQg8+3_Y>FuVFjhO296aQ?pcdxG-R&jcS|xzk}sJO<%n<{GkLB(6(f zcV|bh`L+Z3$EHmL34z*nH?FwFyowda6{YLFeqfdPSbSl9V%zt}X4Gqo$n<(jZ6|ps zu4wM~GRuw5;j!64kCAVfH61j)pTvh&6{+WY`hq<}yLVYUIG;yuSu}m zXJ)Mo%P7uv?~Dtr@7ia&j<)dvZlMDw#A8j(YTT3h8ebtMTWz<0jb0u3wsxcDjT?uK zwV`>3RhMYR3pU1)zt0=E(zN^bkcU1oxI1ZSjlbT;YhMP&!z|)&w|NGU?mTuLiiwHP za9Ce-G0Zv~c`WbuZKVbUcjHG!>234P$C52Ft=Q%?{nyTOx6!2`x%D{tsjI8XHarb~ zJ*vbyJl-|nb$t3`t@0AJwd8=OXWnQO$>U0VB#Bx6+@NeYxOda7Um9cnIeFHqj#@eA zcDB55xg!w0>%wba_S?5lv9r7LBc%p28n%7Q@E@_8d$skcDYf3MLhE0QWW{?QgXw`J zk+DyMl7|`7P=4J-aG^XX(^%yAEPYS%R&}e~t`6PnUKK`zt^NjT+~7T~CV`y7yqh5g zccebm|7Cy8{fjixv^p8no|;p7+|6X4Z|M{iT*#jBZ7LAmC=9as=uH6x)TsM zyZ4N`?y45!H+sb_YzU61IPf(7j>09^?$|rjgbSylh13Ube$I#QZ@8j*%VRi9tlbc| zYl~*i_=@_Af&Xgc7hlbf2+-T_BJNk?Ol3EesXx%Y8JYfsQ=eq!S!)`8^;A&%p78$e zF_LF-`XO=Td%@;TFOvHR&jNF_d17hild}g0H4n&EkY4U*s~|q!Wb(f$(-d=K&6p8O z7Q1jsweJrRP8sLwl;1kAfm3DmXlD`f=xc_ZZ<3?Vr{nFGt*Zl)=UP;GEtzzG`EIjT z%i3*3Ec4WjMVHq4IH!8+*bT7k{zYkBT6!tzwPbr={@kaZmh2VmE4)I|Hp1^ayOna_ zi+$jVZ?V4|53XqJd&{@5#;!Il+;e6{7hmtw)td8fkKbPP-s`$_;7nUgcrR^{_41*R z|7`8Pb2`-Cc?XuHtk&OoB6Rww*!H^aV(IVPCp%HQeV^UFn$No1leU0dfG~g5bgO#j z??lQ|YKPv=ytI$ljoWnrgl@Gs^ifpevEP3UE-a_A7M!^BaxJfY`)rl;gSuTuNK-2w z9xx;nS&r{p@3*KoR^v$F7q`>6po7nXf9ExP@r+Q$(w?B7t6wr)mAL(?ih=q|{>Um> zyw3Hd#%bdhlhgGB(SL4JI!2{iOeDfdlteJ}@@_{o=J_-wYT);hhPu$SdmL&EVo$b8 z7m9mHc+K8*NbWPYQEjrS+V1s-_uuB9d0P5!a83QajLoY_X>GZtaRzz<+t?RBZm`qP zQB^+O#y%8oX&PT}e6qRNBUVx0ju~j^^e&b-N92s2xN7R+cIBw5f50<#<8{vYefPzx zm)1Dm=`QRcAEUum4E4+*}e-FY((d zF1VYV-%xEZ)ZY*=&Y7xE3z0K!ne~&l?#|oihGhO1-*53^Ly_0FzHwWO@baWGz|7xz zknHN}fyx)JI5>L1sytU3nsaK6`YZ~F9_cI1})wHJKqFn8@@?ehM72-?a1ZpC>ns}b;yh7x9GHV#Idh_}VnJ-Zb z=RaPnw&-Fxfq<+2QrGwVDVdT1PtKd*N!Mmiu3~{T(7T*=%}O z_@C`CYJFOBr)zInpd;PvmB%P)~t;mrgx1gJ&jdZ&Pz~-Bjn58H$7kD zX|e0Go1KHFkBi#&z%L)gq8HpYc|{M(>SUCuLi1-B z!Mt&$6GzKM5iUw4RN^h@k?9`$&tA41mGA9Mm`7Oh>7KaO*Fzmq%seHEb7$1DkWBW#fLmsX<)<4D`r2L()J+N+>;A!ZM;3sX*9(ku4jxG~f)26jb5l-W$ zc2?Qw*jRe7A75X4w8f;t(ROe6U5y8NCci_?$=uTZ;X~*6=~1lSuYQNO9?cTyZY%g; zKKa_GcO=q0@Wz6>W(}v+7hgW)OD~gSwt4$^@90Kk5XgO>PrL0{ZC)nb9)n^t8iTwO zo(Xc3U%&s9npwE?&V=*3P2#OeVLskTc_8yg3xR0>eU0J%LWzqk@Z7=B31O zs_Lgjo7Y#hM=xe=-WA;=-RIUOyBiQ&;IU<%ICE)iL*B=8rVf#Z4z;M`G>Ho22?5^4 zF;>>P)8UvutjLg&_noC@bv4PR<2ih?!n>f4PovNn$F{*7_=Aa+msM^-#q7%-*3 z;WCyG`Kt>NsI~mxK;|K>=atry{i6pmoic;J-uA>JKktyp&6d1jPDu1g7r56QKHoPp z{*YUwq55ay%-pU2o*L}^AbjIjpfjXf|0 zaPDRtUQffMuQ{W9wt4W9z+b{BHC?TY$60y8f?OA;G&>h=cJ404+*3BI>Tcb^^qNDd?L3r&q4X5CqCp00>IS5iW`b?&Y@+NDcx$0xr=wAJS4 zYu=j^8}^0=ds!V;e0lQ@qnUO4?H}4b)!=Z1EoqUl)7sebTR#nqV|%qyW18yw75T6B zotxZu>Z4w8u3`?weKg-&Z;y7rdrDg2%1xf0l9cjJzJ?>^oL&Or+x{R!1)-BaJ zz03`IYfV3;q$(SGwn%NnbFnCpkSOkK^-X^jM2?O4Ej*41;zGnX>4usGuS0S%pv!mBk?{LwtW zZ#TD{e8ZZaL)zaS*_Yw7k@QEjMXyC?>F+c0{a?Lr1)8CBoJO@?-Os4Jh<0$gaD5S$ zbTw(z_m9Rhs}DZ)RJDA=&ims3{{7p@iBo~4UU0A_kPzeTpy?#?pfd$_o)iR;3!xS4 zabfTWS`?&)f)BkyaxV|ZEl1^f2x0HnNy&#@??eo61)=2#L^hanM%#yEK-Gp2Qiy{L zgv3J-IpGI^@8*k#Jj<8ri>1uSokcJjr9#2aMa?)EOG5+RP(Woep|t~RS!@u9&uvxc zo`$V+^}=J(km>2B^w<-4=0hC{hWr&obZl#Nt{5e%3Tdi~w`*#EgfA3WZg4RmT*5&b zm)6z?qi$#n7NvI@u%NL=^x^QhJhsP0O$a2TnxI}(j`zh7pN}(SH zl|)R6J)kKIilO5e%1LXN6 z!-C2h8ESNWf`0u%GzMmlVkR+&*ujEe4O)bdv>L)7lx+cu6+9-YFs>*hltv}@O28>% z&Ljo~8V9Bh#hHc$CPUi~gpvaLgGDsxHP#ths^=0wK@N!ynqLQhMaY5x&?qKyr4X*5 z6g(aeC1MltIG&Z5+z}Y)3wX`@5KhssG#&!_Z(eieLjTe{rm7>YgL@H+hQ=POmgOXp zumR%%>Y5bTa*`@(posB+P=*Up4pza1y|GXVy}b^(aavnXbO?0B+6!T74v{1cZ3S7_ z27~lY4y0l|Ofd{jp%&KwW@jS<93N=_0St_1J>>RLQs{dIp>2#X^j`W^OGe@F0y`9k3w8qldTxl;460%%E zougUu0Mac=Ru|H!M%R(YNtF;or)2=`P|E2Dk&&P;Mk-UWYexH`f@d;i2%qpifsR5y z3}hlqKHtU{v_L+|uQD)Db>&m4AleEmMjFm!)ND3}2q|-AA2yE@vY>cI z<5bD2#Kuq&tD+$dSrGcN(P(SKd_xgRV7v`aWb=G^P_~3t8thg%0!SI{{0ssA816_B z8PW`8RdC~cU>S?fGvwP?6=G&WT?PGMMxLRZ3H?8IN=M2YLFY`%3}(M(se@pG--=+d zpdx9Zknl--iKMA|Mwk;4N(EYIZ6N?YMS1E1fREzCfo$rp4y|1#P8>Az^rK&}vSoTZkY-wHSJs!SF@&N$+KPFfrtUJOnG1B8I@qV1@?Dr-`hILIz133_EJr zj6)`v@+#91s+D+mDY(UreOSB{dr!3<`?zAXZ%S{cNN$1?D&*l#BtF|GwMsX&11Ab3 zL;A`QK?F_?A%P1-WMs$}bcCDGz|#eVO&>Ox#Zi1VitQWZ3x^sS;mg%8WI&*Jh3O6e z2BeXGov}aC2;tt5)+VJ7!&8<^z)46V_>RLdL?7g|q%w7Re6-z6CJlKoJkngrl^DQ2 zkii-ry-6KzKt2Eg)Zrtdf?3I`)dc96Jn&$&=0GbUv>e2LGeoAjY8)EoASQ=w^ zAw?og3{NCf!0RN2y$M(j+n@~#qzbey&x6ir@ED4c(ji)fGOEId3E{Sab z-EFiH8}{1r8an;8jbp+oskN z??@9WApR^hL8@O!N`?V67S1iyUS+^1FO@P1$+;=7JbkS#fC&I^Y=~yl)8Mcqb%<|+ z3qoMwpg;*8upU4Rik;`j;guYe*`}&i)%^I!9nO zYl>hV)-bCiwaq#s6IZ2s*H^OeVDz6Zqn1SC5W!91i0u0LlhjEl{cBOCZ(Cx~o8fg)A&>L|aheRpsouWSOCfwRAU=G1<)Xd!zE?PGBC0KU*Gj!o*r&kB_Egmq&KDqV;SXZ; zx;#KzpUn2Ks@j$C@zrGV9`kLJp+YD77(eGo7smN%y_6^?&i9IB&K1+M=ks+w53j#r z97?!(C-(RS;4hDpL%$` zs|*h(yb?|A2J@49jovqIe_eIWUEFw^RJ!%$32&w*X8HHl73s;gRX73Vd+lyzRN*i} zjFcRB|75hwg6JCC;`FO*x=Xq>u~#$Cc?-s2wO?+g+2rz)MX?Igr_EuT7uOX3S*Pch zv<$yF=y%&MO$!#$eO$U=?n-VOHglOf-CVQHb!OSzW$uKKlrD0cTiGpyGMmiZ zLg@BMCgzfRE~yBiROqG)zjwcXdmx>ivvywR`Fg%mibWUPLN1rMDkS)#T96g-s4|6= zT}S2ohR!;g(s+JBh%drB{Z-omBi@((s`c?v_oETZe$Gl;>%T2w@oH~T zs|%`6yY~;2cV^a)#G{e9e3O2 z6n-P1&Yx_yqz=nX);3@mgj2)#TcJI^LZ_$po#iGSY8%d7gm5hWTWXU*1e}e?+BB9> zaE`Y9Dv%J_zFBUfqII-7hhyS1~Nyt{H ziOz1tO3_0@*=N4us#Z6?4AJVW%KR#A{U4H$)0K+HYoCygBribPIxC_^y6ki{6@*1T zs#q~wFD)ZKZ@k~kIr3e?J?(<|QMU;}CpB1EYx%0Gk-EU3OZAwuq0C&GUfC@~rt!=~ z;dbR8R(Vfl{e3s4L9|CT`@LfI@My-3v|GHB+2DhZTA8QL#-y%jpR|SVmBVBq^?W4@ zUjtmqgIa zF3P-B&&bx)vzn9Dcu?binV049qp#vlr-HZv{Fx{xK7i+Q0MD@*wWSS&9ZAw8@9cW! z5V~+E@kjP++#P#%x}d1vm6dT8IMnHi!!PkHgXj#;5e+n3%xaujn|%6`Fr^*O4D zK4CZV_^|96_f++R=ZZ6jRr7BIGE7aVIp2uxC{;ml_nVHVCj+@ruTz)#dC7cywKEB^L`NRxbtc8=bYag zPpL{z)7H)<9qD-;rN1h_PQ`fmp1|X3FpS3(kJ+4^!l%w1NRYbd%Vr9*wKSi{X6v|o zF6Vo-%bBV^uRp*J@JT7u8N$S8e`XfnXn$VYN3=pDUTKMd9lN@*yWuUu_QzCK^Ffyk z7Qqk~G+L{oc3{}@oaC9O0V-BUPh~tsWHx2;orG14Cu~2WSM-BVyj#k<-PR0Sf4yfS zvp+;5?Wpvck$PX}8D;8-ox&0qomFIEVDs@#K1Kp!V4#Xw>RNx~Kg~bkQ8a0QHHxT` z+ZBqJ*f#v4RLi*3pmF;7=l#d-8LlX;OCG+rmTW5$qsf1^q+PA``cXny^V!NK8yb8O+H8kcnEmq#-o&9NS z7JWuNEl&7Pw@HtE36&*({n4V|aeC?%to6-AlKqBk>YMwQ zvRq{BdgN@mhczn+tu-Cx7k69rzbb1EkEGePG^@l_E=#iyLegv2V$PSk%_PkY|H@Jb8o9vSBK{_RuBrW?Im zxIjT(L5^;xhpy7H?OHl{M977fFL~d6v=;qa%jvV`xsglM)Yr%CXQ{&# zKYMrNKR2f@oY8ytOl0! z+M9T(NSv2fRw>9;uMW|dhNx6}TJ263c2nG~s5GYdQ@qxZ{uN%6O2};Ve}8-Df>HCsSHkzsFOT z@UwlQ;c36%xk>uGdWg05yO5_U5TY%b;4$Jc>B!5;M&Z@IXvx2BCbSz zVq~wQk>*7@GsUKMRCda)uKk44`LEq;g_4qDV?RsOZ`ig`u)?_mVIdA950}DM;o;*6 z;rWGk;n;{YQ{CG)o(R4z?u~5k{PfOy9ab5@*pO}tdmC>t9N>B|Mg%vjeb1y75tH2G zc;H!Zd`NA|=+@nhs{^I^M45w4$?{g-moQC1YZjN}YfjSH+Rew)skR)u^ZHV&dJx!gUX8nzfCsSGoSO5Ou#$zkKSE=I<}7r~3DOm66talIMxK zih74}TiGTRf19(v|J~uEqbvP-OxZb9cuuZTYv}_~Q;%(@`^_Ow{ZpXT!o)9vox2|D zzlXE*l3)Bh_ESjs5TNsWJs0Z=bK@S**DhUepfef?(r3L+>R!v^R59 zF)RX__(Z<>{-t#>N4e^UK>u)Xe@>C>AWvCl=upKdgC#GcmC zc;#@zCNwyH1^y}eigC?}xbfwJd*(Tb^qO2tV*ktO$gS$Qo18<2<)R_KGTolsbNv4J zcnS3--HK`zD*ojjMP9^$lpa~_({^#{*omaA=+HAqr!yr@>#1Sy7k9{zrmUq;4rv&V z1{RD&p87g@&$S^fH3G{JO)FU}_;YDXb~Pac~!yFYjT8 zcL10Us!Xp%f>H$o-9KP2)Gla(ft;$q4!1zz031r52&O2a?GO?S8GKu4z~6`tA}!qU zS5DaKfbqZsE)GnC7i!y~GX)e7?9B80P3@!^FzpKMD$QmqIUfKOqO+)=%Qqwk7@by- z$jt@J&5_|C{{b>EP8Bf>0l|vl@s(gO9G1lecvhL*15k<_LJ>(GO&l#a*QE&Q4qSIg z?IJwU1E>oTI3{b900V#^|$%ZA@E+zZBx|0)l|a11yW z+*osfudLwzlO&yLAQZ#GvD#c<8HCt&4yKFwqqtDa-#cxNG(=jONfq>Bu?X!NAFXU0 zo&kXa`5H_Yfn)UmxRM%JsNEJQ5HucEvkGh~@~E)}e56Y{fz*qpP)BQ;q1;e^IY;r1 zFgk~8pTmR^z>yF1Xa(>JOuffuD4xP1v%nxVC_8d(`T8;8I96yNpQ=|(`a7NMQUrq$ zkT^R8+yQSVmO_RC^B^z{3ix0On(FWu2ck21L~SqNeS|WoTrjDvU4;|@yBYfOpcsn; zxkjcSAV({eOh%IRav?f84mA(d)CB^Nx@mz092yj?8ta2K_`yQ(^FcNX0xh|sMidyM z*UNDelB*$L!2mdDn1*7pAjqX;@rOX*LDm8WMAuM^04vSJ;y~fs0SkuYTmJ^bp(uh5 zP+kgm7otccHCr~2w2?bPn!rdhB%6Wjg#*Uakc-f=8O+fIQ#oQ(b~6&r0yE9o;JY;2 z{NpMcfxJ@25ehbJxVC_vqBB8Dy9$QY0RSEnAXvab2@IK_Kn~IoEikhJ%GbkW9c>35 zczH=gJPs7-p_+h>1FlQ`P#i}G96EJBYiPDsz-)95AimPo{S$LRgT4s_*x*v&4bz}- z8d%0=SOHv?+N;i=-9rGyWCD|eVE`qi8w5@r(g#I;n@*6x1oC=;R8P1^A7p2e!&D*& zfts2bH)Lgj0F(&E>U%&q4m5K_;0iT#z|%Q!3QLa*AWkL&hQT$o5;0ID7{Ly-g9EM$ z51OpKaFCaRT$ez=+JFlR2@1zRD1hUDKo-?d4CDkrU@4QILJk;S=!i~-qxD0<01Jv0 zQcwdjWrxb9Y6KwA;6x00f|VlO9Q zeX!XA#{<(q_Fw9ejOpyL(s$LfLx|*q9|=RiFyJ>&)ptcL=oJ$29DhR?7c5O7!G$pg zjDg^KchW&ZF(CN|V!#5h-xE-fi-4h7A=aXL*1&$sBx0d8TmmecK$GF(Kwjp*2lUcI z$(;eb9x})&V8qZc;3ME@VUa*2Py{0(Wo77&G9=efZ9E9eXmD7je=K-?a=g3% zCL3751nK9fqcGq0Z=h()pIbAv%SJP(;N_|0adD$fJTjT-MKp8(|MJifV3Y)mEEx)> z#_>&6B-R{gnX^JsERbkd7RoT`USKb#RVAyJUZWvHv#kWpbrB8%N@pOoJV?Z7@VFui zz`$(YJiDMs*e_29-wNh=cx+%%jR~X4N{eL?;lM{qf~f~IdK3W(W0)4GqZfFA5EiH& z9k?M)>;fiDT86{|wn(trF2J#PA$B$bd<7B$!@@{-W;PJ90zMT69zOv4Ay$XLq@j^E zd@7)sk?u5U?%#zV1jGg~ejpzt(EbKXcrWE<)*qAqY?hkKA zGBOL1#0;XhcPL)|BjXCQC7A9QhTWAVbs*=X2YLsVAx1l?>@XQehTiJ`T8@G@TB&TB$sDN;HKR_%)O}M3MRs-?sMz z{Cy%7F{0G6*tCyv$!mNnVg1`6 zpd8)7>e3w^e8f^l-|W(W zkO1?@((#9|f*cBRcG)536DD9W|GSJJb)WE7c&V##qa-{zU_}vjKRV;U#}oi_+UKhL zj~01|6eJL0sH)Z}Y{#r0U?|fbl>4D9RA1-d*kxzWDcmtEEVG#IHo>3a!9V_JUfkE3 zmuRmr*mJzW^5WibM@b`(ai^-0ujad*%{N!=MQ$m22CI@+3)zQN#Gk6>DYYjKloiRv ztA4!PQALB#rM^yH4w7oFKB^?DG*&pWNm5Q@)smgR$IWg2vUpG^-cGt!dxUfP7Jocf z;dyy_$gW4h*8PS0kyjP%Be1DqXN^z4PFmen(^welRq$vmxvyx|bA0s9mychMT(IYS zI*jhsx_z;KAL4M`&+J!n(<5HHk4c85kqT`Q)x7l@0_2R^0Vv9u*p?3)G)V;#Tw zS&7fr?NmQ0drX|OreAP2C(;kGAs#^x|L%K{F3NQ(0^yx6d8%k{gGfbozOuLI{VE1eTbpjI~|eDD<@BLJr^)8 z{WpVunIz149`I~TKIwWi=jISfcF*ZQ56Rw@9c~(T4G?*)j8}J^(tay#h%m zi{0-HF2shb`5*oGrIMe6Lt@}WwG;=5CxRO~8jhpq>ads@aiX)qe(A+0E4zV0o1Jn$BtAH?yHpRghIsds$;=D0T@aJlot<_n$5Qt~Hpq($$ShQ0B-|v{y3S6#dbreq zAs5s-_u5n96|`!TET$oIfbe3h>(Gd3|6J3nd3B-Qh^vW{w%?Nv%>TG*f|Q6qc=XkA zUv*~1RRP33KlQ%2d}o*9c-Hx~-ItbkSS9ye^y(VYviRq_L&f8%MJGd?AehZv(V9p} zurugLJ9zgQX{oP7_P;sp-j5aMZ?NOlTk_?pqVF~~9ACAnYpR`E_xjpaUa9qjZHK`6&mXVw3a-uGtf`^6C{U5K$wm(TC^r`y#d`;9WRx??B3h09(% zKFaX6;EL22s%sIcyqSxlr&L^Ct5}2z#!S$cp+e%x|Dq*gv-6`mmm-BjIm!g`5$cI$y~33FGHt(ie=q6$;fI zIWMfsE5DB^T{3fF^3n(zW}!Q4Ch~SlN>^(bz5CMa(zj&$MAGx-+5dfNPx?l?)gN9; zC@y@RrYG*Y-gD$(N2Ww(RsDFZEHBP_(mm_w%SP6~p65p-`YvDZX?y!*;;!Q7)6f>% zthBT}6GtaD@9y2#Ddr!E60u*I(O}k$gN$9Ob=*}fS7hxJ^ui;V-}HMquMZznl)ou{ zb~n8uOu2yJuXO1a_B8{C$4tBbh@#j9_Zk?PejUD(eqiuDEX zmnV8n4H-CVEax)sp*MfI;QADfBR^zf#zfS2#UOd^#m}3LAo{(NeeVBe%5u(tt+Iy8 zZO{Yf6!0=NVWHvv82Jwo7q6-9ayx0J)*Pk|UEa#qxu%*Z&(1H?rw0yxd$IbMTc=eJ z^$=rkqDF4S&q>Rit*0O!QcwDJ6)ugqzw|3KP!ipmk|Anv5HDYJrp;d;)5gezwMS0h zOl~~Unf3kM&AEEpf# z2cb64&Jtd-;C;|Pvca*9+pn}7Ju?jFobx{H&Xg}(po+N~`Tw?SlC~b7&s04AhAsHH z{<58YpkIJ8i7Cq#vUZjXcawUV^@x8~G*hy=y66ndKKy6EuEiCN05R4~>iciCNn(}P zbCX+L3|zIjV-KG=7x4Y4qD1WB*vyH$61s@vR~IDr=M53HJH^aC)L1urC7=KHigfau zZM(tfqnxTou&dj#FzkS#cx_JSX05iXFeX{`SdpUM$i*->e$#aoadQY;sm9$fy;fJ{ znz3_9hg(kIt^*fUtvcIKs7P0tVZUG#qxs8}U+XY+JNZEbd9+5p^%L|+`Y?P_^jde< zJ=4X@S(egqLKEuV@(fK)G#`8C23Z=D@tP=nH*B0EcV6sd6?7_K_}|au!}mpYro^hB zYg}F5dwu;!6wW=bWrP1EcE~8zY@b->+r#;6Bh!?l(@o}=R5c$%F!hGG;snS^r`wE# zN1}`p70kVPo+oz=Wh6L!jBTHxi4#@hF6BVJJ^Fa-fBzhR%aX3h zI@({HI;_^?sifSVLC51})r3=3#g7YFbHo<*cIXSo)*RTEwUe@zG*74a`7H+n&73&f zzdW_NLxqD<8mL&b$(As>l#r9RMk#undr-gk;(K(KLSP-KFK73_38-V=dABviK9^&g z#D7H%xJ0u}%qexv>0h`zB2}|J7TVV_<`bR@OGh7JEcAR|DXRLtEVzp!d?tMTUmV%T zBqHUI?W3R3f#Smd(p(e@Z#dqpg~utuN1r8O&r>dR=tv#aNs`~_{pZS+!K@!eOOF`c zriw7zwAMWxH>$qnytitnt>*k@FxlIL&s z)UH2wf3ti~%84^ZlM0ekc~TNtHVcujN<}()T>3Y5gPOT-rHNV1Fil{7RJyPGZr)}p zEfQU3dRdY3-0IoWR8jQz-DOcf|9!f zR%uPcW*Sp^+s2F}xyWX&tG^!Da5s_~zSY>jTT$m=u7>36Z=tCQ#b_biL6sRo64W_SMDCxab$ z>T0g^LTBKZ-g0Y6vHkBLS*vD^?|Zu9x*ay#74G|Bu*o|bsELIy|=|uwQ6`l=(h9MLf4gk=g0~RMh1F(+_kT?*f0G5lA z4LpI?(zaz_ZbQ9e7JyUeSOGPVL*@XZAL!l!7zV&dfRO<$yjVcvp?JK2VSjm#93F38 z&53>XmHWx!oOEE^D*>>hnnfc-Ra z07pg5b|s@h!;hv77EAu>&QQa>E`>x_9H*ux2aBWj0GJ4XZH4GCEIL215v~m|xz=j1 z)R77j89Wzsdb6`|0cCQa_uC8PavrVL;M_n0Yg;Sji4^eL5GV_hd~uI zhe-u0I}lWII9jsOX@b--5hj(+1Wf8|7{0REmaGjZH$gVh6}asH&&dtLaVYs993tXy zbi4z>9jgU;u@EhArh#1);I9KNOmMoOfToU*0i-1aM098}2L?lDgOgi8$)Y#g^pN?T zJ?^R~HK71&p%6ROKrlZd_Qa4N(h({L%jEZ9v@7ipT6hp^djUseg%0S@%gW#j3X0SK z0@-K?)RYtsBRj3L2djm~fK0BH2aY5++qR0s)ofO0vjXPW9I`D3j`K%>Eg2@MAsuvw zn;OYDV>X(?q1gi9RU6n!QCe7=zhfX`Oads?e$L6Ka6rTcV**(u zhKQ>o!RQW@RJ~S5uq(s_10Nui@;Btd3BV}e&O(tmHUV}bvtZhV49<089F9W?zm_iF$ zt)Vmo7HZoOSPAB2xJWpM4n~}YX_BDuUkHZGu~;!m7KTnl0yhAOq=PR+x^tKWjh*ts zdK$Z`_`p~IrL%@X8c4|kPbYK`GJmIt0(n#r)yit&+{sWn5G4YusecTaq3uvp+1lj5 z1gZdu>*A#vdWgs8j1x*An;~YpdrHKNP`_8+n#_>9C!!BlRCf?8w{P^R8>=@U0J{lHDof8 zFhD>L#keYHfogH1f<)TfP|V+KH(w4+u>fmAXaL#osx*Vnu>~3G7(&}xVh<=En}T=q z?sS9%bqK`@00TjVT0pZYlLg>qCL_~A>2K^O>+Z0vx^1?d4kU+nw-dIVn#vC{Z|XV< zo$gN&M!Y^~s+H*Tk~xVT58-@G8ykr_YT15u_$wkvysd_DQ0spWBW{dnCSJBr4jYSQ zKNFTvKyqA4gL$tVwEET7-%~TE4fO|Wl--`voe0k|SQ;WdX^*6fqaL0~OHvGeZa3J2 zHUGdk^PC5DUVEm{e4<;k9{K~9 z@hnXPbI5IHz)WM3V;nR8pdMM@yK>M~M7p0SN7(xI;h`GDnBP%zRLq6?WAps!lAb`! z>`i^#Zd;Rsa^zQ!1Jr9B)D3dRm{JNxXj5rd0CumYiKyktAsq-au)-UPB2d8%Izx#9}_SL#pl$=`1gk-+P{1w-TwEiRc3D?Lc>$R8s zlhSv2_+?94r-jM2y5HY1G=ARwN6dJe>Wye<@6Z1?KJ=MsD^tU^$6x~WUji^9UF5lk%u3Dm(P{3 z{T$6ecbEwl%o47R5q{qF)pwk-lQK{=A);Ov3s11h>nSq&eha|rQ zjNC2p`L~kwPIUgN-CO*0+iRsZs}t8U#3*vr1v!yXcTdc{dT$dw$F@-W+A}{BG9;qz zal{bw_2ilL`ZK!2oRjaR94lm_n>arUX7h$gIkNfvl2 zxli+T$G)uJxZ=Y$$GiLRZF6S+TNOs?YLW2NS1R^DVL>*ZK3%kI6we6T*Bz&79TCys zTiEzmobHd43VSfliP^`CIrZ!sTD_sv?wM1d@F4NefVQ^=Ysbo|B;juV{=}Xzx|{a=m zORjT>yOJ2pt0Ip3S@V{$;>k}kUfC?2s(1f0YM3Z3?1jL*i?Bn_NY91B_+~}t!h~Gw zpb-zO?-(fG3doP?m}XZQoj#LcRm)9V`8RC5xEi^aicBlp)pN_j48u4d7o#3Axqylv5>xh*n3&hEmT(2cvcLkeye z$=4V#9b-C=z@L%GuW!6OBh@&Fj(X&XkH1Z5G%b1Ow072p^foOt3pe|AF+-iEd6ISe zCgGr_`2Ihq>Q88ng%#be!aTmQy_k9X$+_tWX@OP87X{bZQ$McT6t0LWMO>0sRo{0E za-o%x6}!8t@lV<{#o8};hp;QFHFpk9H2G^a;&Rp8!(TLi%EK~WoEF{s$>Ds&N!>hQ z4t06WQ#DgE#$SyIyatg9mDlVN|9QlTA@k^RsZlcV!B>$(GM=7hLVo?D*5}|2E%oFM z7MH&L!A;FFRETJe;@1XxSG@eEJv`$B@Re0sKQdgF7OpDu z_>QZP<2RiblfFqt#M1X${%ual4<%NoQq=F?j<(TAzjsk71b;30NZP=^Aw?<=hR1t; ztF}p_ems;k%503hB5{qk_A9VVhW?;-Q!cy1c+Z!K;#|{f&SD~O{xUWRh;>3hp!bFT>9)7pD$n$CZ;b)Rqb<7nf9jbEg|&d&F# zq~O&osju)h+O_^DHH;Yh7KEPh(bGnMt}Sk3Jrz|g<&ZmEBdRb)2<|t+Ayz}0 zJTc_WiSu(&rDIM3FxNNsgI-R^cApcMTVgB|6krE^dTz)Ke(gQEBf#*e_-8I@Kk43| ztLNln_OvGvB4hoJcqbMHFo{I0-x-DZ9LK!cFY{PD1Qi9lEScw#P6DTRimj}rq0)J zc&idUqbjxEsqOyRTk02D1@>!n$J3#nbTkX*cEjZAuzcOA40EI@PNd^&1ra3Ow|KKM}iR)P4e z)AjppPp7`CZYW0eixm`}zwkRJ*bB48ySj1AeNo#&XZ+rw&_6;~EKCNg`}J(frEKph z2f@^0Rc73l{DUNIwwqezId4%D1-jRi=m&f|-Ff~UQ|Et;e7yIK;WNx~Tzrk1*s6^@ z(ECWbb#>^xdI4gPk}nFYSzVWwT`eXzHK0l+Raf`uNNHu}^*Lo2^FV36wWngkKK3Y*4rkKPc1(4+y{?&pvlhKWc;%+6 zgsQr*ydGx zp)q+#mx6p7(UKtcK%>k{zq_+Gwf*2s=7rN1yPBtRA?5PjZ@(Tq|2Se`Q>H3cL2B6< znLztd+;KD{tK`5w-0d@z;7Y&zLtcZ<`sP`rY6E(K3-t4LTw())rSg+4DC_JB-IxzC553e6vZ)G*dS9? zwDV!_xs-jv;~{K{8&$z!qeL>0og8G{xLoyp0`Gd))Cb|sFG#x5=n{7KwY&;*OxwuS zR`WLObWC{w>ruSY5syHIeDjT( zn@B!QWO^!=d?D=AWQ-`S_nox+%tpzR`x*;Hw6?PIB~l|wG{J9#tt+KSjvf4zxhUN1 z_voT~PzJfW-M{IX{_JFE_^Q&PNM zl2Y0nWNB#_CfYl$WA-C6h2;a0_{#Acv^KXo&G;Az+Y#&t$R>7M?vy3-@( zi;dVbuR~9&CEhD6xALA8KO~tW@s1?&Y++;kxMqTZ6k|W6%4sx5yCJJpx!|@kB#^S( zK|;yMbyKgq{DYgf1nd1hUAM4ajO$eYA-!^1rd90u7cW-Xn0KekQeIQFHJ09^6wt8a z@ObX^&0Mzk*~HsdWKN_m^^zOa(%+N2Qfn=og;emX&+1|{5%KxD*h?!#+vGC*+1So#CYKf=LvEuLHwTnBS~omRf+p78JcD9 zl;UB8t|vr0Z&($zMl*Fte8 zYB8WG(RQ7fboXt4^-k?Hds9VtDB-!68-V z%28u`>AL-y_49hn6*&un zx85rj-(-7kQS{YyP#{7i^!Rmu%E5KB63_z^~ zf;mvoI#!j_N?nuVf=c3$T(fOe6R-F0y?JMMA?VT80MHSTjvOT1!GQo#1vn8%9mklw zMg@S=(PTJCiIk8wz+y48wK>olrmiqpy0sPH3m&LRGtq$sx&SdFGnn8$9NfH<86s(X zCKz>5NdrwxTPy^F0Q?~68S2Y%aUjR6hG&C_(+&YfG8}Y;d(Z(Pj$l8A2!=~(5U4E} zTvii9z#%puX@P-*N-Z;l0li@7Fj%+cfvzT#PVyjWw|bF4U;}pm5s4{)G?6ms`~ZC* zdc&x94ueFN&!mEw6=>051n_Nvjq(r~I$(;_cFhHh0lpLIN&rj0MKw)CoR@|SZkVPG z8ij!8GX>Zu2?j%g{a_<9$Wj|}m>M#8@ZCtXM-D&~I|p+BCKnpUo(m0Ffb&Kxb12z( zs(%a`929&ZOhZV{M|jqd71-)PL)!F!uTj+lzCw`mrHl2tlYu9&5hM!3tiYzfDm^ko zS_|(8h&btNzaj|+=oe=4v1lq8G#DvVFkisZtI*Mfk*e^2>%K%BSiRt+hBSj$Gz%D> zKs@Bt#3SNi6t;h28_J=X!v!HBhYjl7vS8;M%<~2oID_+-Uj=&Tu^a{S^^Op* zbRm%fxK-eT0~sH^Asq~%0P7$O??uEh08!aphyg*cAe$O6i}K)VR`LWGuwD}i08h9( z0Z$qtS2k6FfQ;=|3Xx(rq>HfVOgzC$+~17+mk$FyT|pTNRC5KrVD|%TzW_Fr1FFt| z;e->wX0;JqB9-4U$B~6V*#V$K%Y-p$V9#Wf&Eww?0?=g`lU>CY07IiK3mhlFj{!Aa zAkZWN|0X1Ln8O9vG6L)a%T!hYs*V`uu7$Pba9E)M6Dkz_XmC~A_>~LkhlXqEz=3mD zhQoAdb;N;_)qxudjyw_IyTC{{#|KJKHt5ZQG!mWGF$!A#3o_szPqVFq5nT;I&>4!A z$p#@PKqlp!h2*f>&01J#DAX3D*$f#NU=P_%JUYO6fhi!-PBkC|2noV)V3nz$R=s1w zE*n((K{o7a$iWFPOx*Ao*)^1m1^eY*FiM(04X#~71Dp^D!x-AFAjZRhz}ZIc0m-Ar zt1%=8Ou1N#bpUTb%wOCW&je%M>}EI&`0)68Ir-V0;8_Jxo1`l&pn*-UfofveI=Ct?he{6MR>4e4HeO9ugoy`UKR)NLjuJ)E0e~M& zM~e&v>>=1w2XXWq2g3zLc3!hBhbBXVNHdufhEx*=1IS8HlLl#}zo})4p-MKBgGC26 zYc{d@RY({eOc;Tq7iJU4TL8|^n2$q z5C-O-#{P+%_#6DmCE9P(x9hh{wllWD>}iQfIbl21BcdR(nvnW)y`t)QWg0mk-L$gd zow)k^@qU!o7*UYr__Zp2EIwgm?v&M2Wu42y)yH=v+WmXC+v;G>^6iV*9j|^HiJave z&W_D?du%*)z1ca6cVwb@IQ40&#+btJSOo2;SND_ODJ{!p2O|xP0?)M+L68+p8Zmhng)wtjoMnOCXjcgCBaJ?U|dI~P1-n_@HzNhtyg}wyjw{LHX zVrz7V^WL2sKLm4=FEawGZ1JiWt_MZsTw;cLo(1K<3= z)p%SuQO;L;f*3D})$H)IBwItIiw+%A?yglkp@yjq3@#P($qlQ=NV|nmAK6~>Xj}Z* zu3)6m1AWnancnSs($7DaLUr|orCPiWdaQPo>5?U)Q!ct(tkNXQn)-WRyFGM z-KR(TcDWrvvCYo$mNGf#`<|xnhsT8c_hay}>=jhT3mn>b-%qX9QiDO;M&rYR;g%Ca zSuHwS7qcPKU&gLDRGZK-bV+eme(bmvs%*XW#HYtO^XZ1Z4K8hlgvW@khoN~Q116eC z>vuS9`1{Ry=(^W`_U@wJX8)@+(1IUK#9CTKU;Wr++(9$w8gm)zN zEx+#B%HpBBDl$s~@-Cl2m5iGwtTc1qdY!GA+^Kdd3tiWg@7XA(X0tLzH6>&}*<+Q? z`P6C4nEc@*^UzE(k-BH7KHJr2v9oemNwR8>kl@wQd=l zPJB~0_uM^1yRYuoIhJ@0fS4gC-mt~9Z2QG40(H6*6B6{T72K{gq!IRPME`U@M@2=G;a&A!oWv9FbIE=h zNQrU1#wBPS^J}KzM@P8h&TUUpSIaq_!QZD%JENEXc^{q=L-N{6-IOchw&cKv`nOZ} z-v8?5wC5#P8u97=r?t(19hUMI%{5*zgdi1jPRRG*UsK;=OEpX?q$(E_XSMR7*;=XGwnd&CJHM z=I}p7**)J)w#l)7F<#j{RkrIGW?9F2r@hU3_A6s6W#I!AK`IzqKDWEzsd9yb?7*q9 zgsHjZjowZ4ndC4_{yNVfQAktZ!yo1--x5`6|=nM{8jOB5zH7L!IbWTYj zF#b$5S8~oY^g?^=mjrv$PoA>yA$`ryx~DU}S5BwwuCOClQr2YEb8qa!bcVLL+3x4MXGlZH$CZrtzlkP=_~Ww8+Y@yVQQ`n81<_a*uA*TW;V4_an+K74C( zNB9r<$d+jYV(ioV9EFCP&M6)X}rLd}kVwupV8`=)En;eqw27t#^L~KZ#JptT-v|3k!N&cmlso@j4mwvpU&GQtfa4* zHdl3=->H=&_sJd`xVC=EE$h>CRZ&nWPlOUSPI+-tEWqso@3Vg4V%%fgOA+Up>zOTn zL*nh2Js%8CxWo@$(HOW-v`9IFbd7peW8A2sg@)8XMdkgS_R$%<@;0GlzxDYNX^B-``ORlRE~Mgotb>cSVgN5AJemm_&s+jwDp}jN%_GCuoDtwZ>J1%kMP)Tju^G|Mrt`SnxUp^{* z#yEx-zmcTIT37S|n=;PRjOR_9R*2EM@g$YSURQNO%lWH;OS zk@r2spG(1~vn%dBFnD|QhWcZ+6wPffBfvf=!cKkG#gVf3=Sh6TY~i?cR%opW(-^mI zNRbzmD}qm^=!@@`gd(=X@^y`4VYgpYNb{mMciOqt*(Knwy!o2*71LS#r~u*BawtZ* zc{RP2d_V8&hj%wjwXq@Yk{u@XSD7~RlPl==cKoeDt{i<(C2q3)gGAig*U-EJN^Rt+ zecx3FrZogDhx-?DGaq}@+_sK<(1a6EX}e)exu1VTFgFjicUc|v^$H%j5}lj1<{D(K z>@OoKczZ;wnr!YClvM+d?wyzZ`l0uRl#$h1VgE0v6?ZbAxvzm5i&Ko2SM*sH9nIPI zINEdLYfzjlQ+vqhS|lns^xB+s&D)b^p_-w6Nn*}PohAC-FMc${j=uM#qN}dIoW^gf zDZ4c1^z5v6Xr~Da=lMJ{2%%1*rbL4@7I>sD-n~g4d6h|LBhYu}Y#d?gO{*Q#j|EWF zcI+)}E@_il*V@id?16W`3v{rC1q>sm?0!i3^e(8VTI@ZhC^3dRC7m#` zgVW5iQL;9tBvd+~2$@mLHix8)$(+g>Q996;QwKyvN2kuZ(@8r1-hTg9_ifj;YkNGr zKF`fd4rO|B$A`F*?X_rAM2A9Y3Itx_fW zx*E)Ned)S==-=X_7gJ9<{Za9+cTBiof@;@{q@4YByvo&L%2B5#GY46pefght8)ZNu~c&B#m(6KAufxb0NR8a`j?%ko&4>8wb0PcNd>m63emd`uXQ ze=EW81MMlj=1$#rcfKEZ zaYS+Z2j59!Zbcwz_1@jNP6s!8`R7#sGEc4FGHCYnc1Q2sk+-97y#L%4RYkib5m)x@ zzuc!k5$%fma<@7!3rFQTd@ju}NhQ3DKOJdyid5Zx+C~%*Emr*E5E{72J$a_KyE6F1 z&d)n+Th(q`~DS5K0e-kO)q%h$f>@s z>qiXYnqRdUmqt`>dB1`amY>;?QWrBA7akJu?0V4Mt)vY%tIcP-!ZAvxTW`PUUs({- zb;3#Ysea+Z<{NLRk8monMd>|f(BmhbT?-hRoYS&2y56-fqW;u%#*VqbBA*noB|g-# z%%?@s>r>_?yMEFIm$R+AF9)i$jzvcKIxW|!6+K#{RknS(*5T@qDEiN>dmXGs%I8v! z<(C>SxcL6>gMEY3%Gw9HMFBm}rsM+tD*e#TWI@U3kXfo|?HK+@OOxpz0eW=9T4y`d zeWazIKttmPzAtu^aw&`WQP3XukLifS>>Cb zDcBV8Vc*^v#JG}{(SilmiLncYsdTHVT6r%BHldD3Wu*N^c?O6cY!DKNx_lircaS?c z*xNe{_FhflFw5GS#blDa09*qPGLeYniGhiOK{_b-;pD6Ub)20Sn={|gg=9opU*m=A zBbaLU5D0Q0t>9H)q3p*aZAakoFt%oi0a7l9L-Vw%0x54P%}NY%i4dT$bkn1}@EOtM zDlu6&KF$H)1$ExR&n7Q`tT-`Ic}2bb-Nl0OX$t7rtB*h7#3!qFH$EFuRk*>Z3?l_#kCiq6DzhyCeX{*0{3X zK0P#x747JSV+`02W11YRNFo1IhpEOfY}2k zDaglA;jXA<$l9WVxXfZ81nmb{NFv5m3y?YwBnCvnFiDr)X)m0V4ED;$49FDdRzoip z8j2Y^+QC)}A)JX_C>LQ`K>(I4fIg4E2V(B z0yg#LJHEOC0TG&#JGLm=wT4Ot!0xQkv9>f63g_fLTqo8-GXDP$(fs=lU2{`4n6dNF z0b{kjkPfjS3`jQFO9ZeSr_3-_bB|_K6#||JwQ(Gi4y>L)kS+Iyb+`e$b}x86#qFSn zsD(Nvcc{r8yb;?&Lkp2rJuF!-EKJG?*|J=efEG;*NU#@1L(12Qg?JWjomyv8yjO;u zhp_>T%o5eNS&32bK=rW&g^KWY%tY(Adg*b{c&k?9{NoC1joB=Q5I8tM@6cdY2_WQz zLWB(H9Joi|Z3*=u*McZKS7arXOnGX6 zV5*jPEV4wipyb+G>_rSP2)>5Gz}bO_4b5m^&9GLW#D(s(gaw%-Fv0UdDnBR)K;fJ| zm=&ONO!W2CC{Q%^?wpiVN#UbVkYIq05mkwFbj8-5kT61pnhce0NH}igtY_ZvLKZR@(T>H^l9=E&@Ib4G1oO(9J{w)O5Rf%+KYO7lRlOo> zG)yoKGOB7yz&yi8VJwkWB^DmW>Ifo-7!X5&_z=Em2FXG>u9w@(&*3#41kM%G(wh1b zd^9kd@SfRar2{A0V+a0c+>TahC@O=|msyy>6eh&b0_;lB@D%|zY2_XDttFc2pR$l( z%Hep5VVBRCjfW{l8d);fhtC+~)hWx}3@`|SRS8bQ?r@}8QG|owWC1Q14>4jd$J(rx zOd|8LVeb>tS5Owi3IPIX0=4o13Xqhw^Ex15m84r*Q(V(5hCRp5K2IL(1H!3?n54&< zcet2h$b(D?IL1YUdTBF`8A_iodBfvUs0nOulOq7h_9B4*l1othKu5pnpbwmIl*HH~ z<{@erCE9eb`dypN^PtGz=R1( zh;9ow99eG;xO)bB8RHPq2P6P7-8nuGJ--19gN4>8L`Dx8kYp~bAJT&`6bVRRV1ppI z1f|k3e*yv+=F9TQ`LNO8DCuANzqo&#f8GCH|7-u3C#}j%Fud%YeqlQ8m}N-9i*E+l zlv;Gfi>6KqCB8nO?#(FCkMOKV8{m(DrXDh zoM_ykp5B?@@T00HL>A@xZqH7{-)*NaeVV$-I^a9Zc!koxHzi!x{tT-aw~ZU@);V4% z;DvPfm3rUz(Sj22GrfwQ}9DLCa+aIz>Lc)si^z^oqAw@whi~f{1&;95K zG|0N-_alAs2*NCK-(Jc38b5zSOHfbxl6v-D_{ejMldrV==%@e}w-q@Ot?C2&R_4eq z9=5|e<=^GY>zWE~bM!XYze}p^b+Ag`FBZjmz5R>WPoa=zuWak3m~K*B<3TwPpZBMz ze#^f=$7i~VPfd@cX35uV4N<*oI2UzK`(3Z!+YqeJ%G;$gEtghxZ{B^#vG&RRuEZzq zXER=nM|~VH$}*^SXxjB%B`>Gz45E8mu)O8foh(bA9y>e!JL}h1b#S2M%h0&yNwvWE90^!BRtb?=-3sd*7HXKJ8_G6 zdaG)yPcJRG;N^dy@WIeS-!At_j@nQ0+)TLpZh^hBj*)N5A4laOL`##(|6Eekl@-q_ zcD4k@?LMM?e%P{U+pNaB*6YuDmzBkmife2lyss42f+M-{STW(bC(Z-3@-1D|-bsn3&CYFnR zH;p{Gr08}n+Q)~q{(|)I{eWLTPYGtaTWCe*!b^uwO*@tkt~EJ48vNdubT3^o{%%X( zDdDex%+G!i)62H)PDK)s8pb0#vnC``iDptqcsu2&X%WAxayY2!jB&bg+kkJJ-@uct z3mtT8_m=Nbd}o-yO=HiI6E(F#Q>&hSV9ppA+vkCR){L|C=&NFTZ~5?L6-!EfuT4r@ z{iC*DQQXS=!+iO~|YRa80W z@8@Px=1Nh~7VW5Kx!tdKjb_$tlcuEJZMKi|iagr6;^P_00ljuxriX+<{e3e&&(duA z@C9s0R7Bu~tXIB)XSE(bCV9T}QB9j}*Qeet z*wIvuF=?KzNlCDmd7>Q;4GBg|RdYvfo*CcTRQe6Sh}D#ze5}$nH_)cuWH9f7#jLRB zudKn})MPKirExRq4WBU-*m%>=DaUuyrvJ`1{ZlCTqHMO-)>(O__6x43+wH1^57f>ws)jAz`d?VSCG|H~U3ccHmd@#;J^POGn+A5)=oqsxUxzU_6#f3LD(Gw4 zf1-;+G{)Tfyj!nxXf&zB!${te9GHoS(5oNWr|}mw)Mqu zm8VwX-OR`2z;$L94{rY3jJVv4+cT{(CG`A?qWrCGoY+?$7eaLr;-g2oO03;^FA}<# zq&+)6q7%1QzI|C@-#X&j;nG{@Do@NP8f~pa-oa;THt(9)^Sox)SLwquTjePoBx7&U zhq&>)$cSR|20#kq~$kpLtJxdpMbqYH?l7`XEA#Hwy7)ZPeFRhDRH?UD2HPL zpRHUuZk;^hxopMLD<`Kz*)e%Y@nq++u+Z(^y;J5bqisWN8im=0uLx`+FCB)Y1vWP* z!Z+NPpG~5k;Kz(%VPa+Gh?~L2?CU$HJWQ|jUS?PNiaZ}A?vzYT?%ypqJ0*>0 z{_>tOK|OI@bKuCK0`oKbqo;0qG@Qz4RP+dZxWs2rC2{lL;q`}mq_H(N3!Y&=u+-vmUy820r_w6IWiFbZ8(@O9&|Nca1 zoMbKUo^<*vTWJ%&A?wxGof8%0ecx-aPkQE>AGL&WPrTr6IiMV@Zq*!u%1=0c`lV2< zsGw+9;xa2*qcyquoHM2k<2cjqF5Zx&%Nf{dJN?VH_1jtG)IQdoAH<+n2Zd7uJ%{oA~Il~tNjc3xFHs&vuPb;TLG`*ZdxSm@XnDJd#!p(i?-?en`1+!vGSY}eH*j0LJ9NIZmfb( zr~XMtkpTYByc5{z{eM~e_w4^&ca|+h}QHv0J!vQxE2xrsLcUO(EkNMn#b{&0dqDXXPorH9P$$*ENO~|E&E$QFWpy zsL`_j$Xf5R_r+B5=*co9WyeY72e!U1A9q|lgOiP~4_R^JOk{UT+C-4EbDdRu*{nJBSeNTt7zaqn= zWzMhBUHDgNR7Y|%^Oo1~eJ#$%r(^ddNI%Ank=v`{EE{3FV+Hs7u48|zJ3^EkpNr*Y5Cyj6!S zl-mr$^=6JvI#zAV%$Q0M_;@VHnX2&Kf-ic%dU=8=w`tvRDx59SEL7V=IMaE&XL75{$@ihH@xgCBqBXKjwUrf? zUgz6R{;*SV&^}6!M`~O$IaF1i(YQ(f{eq^VWh!@uBeQW$k1lN3V?_|JQ(5Fa{dS>l z20=e1?|9h9@LCOu_{!O!f`%yf*sIK<;W9>?+y3n{JsVrpB&um!^T)eSj-}zgM_2rG zsnwhgS@s=?RxbXr1QBhr@axqg&Hi&~*liYhPgKOON8Y2{57;Q2;Ppo;`UF}PP?+R9 z&4vuBcW)3wx``2{o2*6Keu8c@R{2VCwgP?QgAZeKSDo>_MXEVDmyWIrU2Sx_w)D>J z4Jjt)pPw$6@NLR}u=>B?OUT)Unyfob#)IZ-qA_=z?>W7i>wCISc_3Rc)-?QVNx{9D zI!bMNC0VrT>OuDC=l`wqVd-%m+oBpr@X^=ngL93O-HJS}Jk@-HiOIpgeoF9k+Fyn2V+iG3pl0~V^_&a;o);%T?@>=QoG-G71IhOjR!{ddy67mX?=wbZKPacP^E$Gk--%&FFx7hNC@y3kUv)$UYb?9 zcc`MYK_RGBv-)H;y4hnR#@kzYAL*B9-FWpn4ctew8#jzCQhuqjAJ&f!^Dm>EwI)MW zX9nE+f*`v+z!;r*)#{{O`npoND4Q!HzMolfoH#U>+QIvwrmz(8gHT6^t8A&P;*bw+S5Y0T~*r0&R>alA< z5OVk#jEfR31YZ?SL|EKM;sd}}fyTo&{?NSYR3eIIu^_^O6*Uo*HhbAZrf@7KxV{-f zsfG19P@MpAC@Qi7LkJLH(a3q{2wON4oTppBwwHi~0AZqUgRz2yqb67RB?wQcg$P_9 z8N{<&RB{FxpN^~Afp-AZy)x*ba$&fih9;Bv0!~#pjJ)AlED_qCL=A->B(i3Spjg^V zfUqoM5NN3RX~sfi5mSecn*(ANu)jit2>}^sZe=gC(7}>%CRk{}b0U{p71v7!MX;Ml?#YqKojtCQGFL#g+q#8jGS~Z~GMu5kVOoyF88cPmE^-MePvYLo^ zH3jT>sW3boc(%TE=0z_8(X*;2rrMHdZPpL-icCpuuRt)L))b`cQ#CVR9yftpye5|l z4~tHNHHuD$T0Mye>eit7A-MP~ZJFG^8DmW~HPJWcz`9*H6i{syvDUgA*jt1=4sNJL zNdQw00u_*XW9in`2%;V*AW8yZ7Gs=c=b;U0Gc>G&Xb@wC`XF@XYsSJJ7HoVv!8J%5 z0N%8!l1L;&g*qy7KEed7uu-O3eF0=Pq`1&8MxqHsYeEo)N~MBU*`~IS0NN7)3X?;C zK|(KU1i~G%_TV-asCKL*;OEX|=kx`8nRP}pAhkqUTB?XFBs~!32bYqelgwod*7(ks zboIb6F@w{ zA$WH(ODPhV$RnD`F;?K?%@_muaV{00N>f!B0np_&>q>fsO^G6CWCy@3BZ>lxhh;&! zh|F4R*gVt@cLGPU3Knv`)?8~V$YMcQ#O%%JhAJ>E01L(oOI?AjwXReI(%cLde6AtJ zGu6@dvecXBdSQDJW6i+Edq7X01T9;DW_j$)4zQrG3)F3_Y0z(HVQN7`)M4P=Y>l$9 zhD;5DbQnDqGDT>JHU-=$sOFvZc3eG2iWBO95kChkTuzh66 z`NeR(V{iw_@EqGh8ynKl3UzBWh;idvTXU(wQW8i(fHwpq3x?1u%IUKb(4cE>O{dL! z(?!}4sX^d2)C8X$RF1qBy2Qnya7DVLrnRJea2@aP(3W%gaD_EeX>llxF)o~!FR|bb zsrCjO^kVZN(u0|mTy_SWQ_$%=8iP?`<LymhPp}8AYN7B8%|gRJWR!D3fYh6tS8a z;}VudH3~j0^OFU+dg*%Kf?^hu@ij>`XF$;|k zS7)8U;qoN!e+vJQ z|49EL{+*Yemp&A?G-fHQMzHrR4E-EXcq{Wt;XP_M&Yl^1F7f=pAy;g{;nsd^Vr#|a zKF6MA@jd!&69>tm3p7E88feGFPn7gYb$KI>iK5Y`-~jyx62#lL<}_J8?OdbM|>^7%(mD&c-^_mhUA zl!tfLEB>u&n#?i8Z@jV*{Yhf#p*1n*;{Wc6kb2GU_8-YPQWinCUia(x+(W;@uhCQL z8`?xQW8ca{j7;}soIyNQ3Ddmw+~7$*|Ml|UgNf*QvZ*>(YsJkNn@uw`)HvVK0(G{Q z7NA;jGH&Pl&Cj)~#1}H1C%c2(4oh#yv6{QQyO-QB+oNWHrc3k8(ETnaW-aMzcqgAo z=HB9vfnyeK+w`wIuGBv)dM;esklAS>`CqQWpGe8)xSb|lPjxpZ|6{REeD1uS7Zlpu zDXn~YMx$x5XjQFb6m1`}%kje32FJ34my-x1=x2&CzN)=cUu}<2!LAB)c)_w^-TmW^ zy((WuI|7}GwDPwd91D(*x%q5D=eMrrrSvVLSkZF|2m@tmhi z$&Q_`H8wZ6eR=Yz-=g*133?XqHh!BXt0HbP@<{aE}cG{ zVt81uJSFZlCk>HcUSWTZA7M^1Dc@=0u+q|;8&p!S+l|qi{Z;<@nWeVUIgCbpuG7 z{3xEEtTW5`3c;U~zTt`r^cLx#F^KX!esz>NMjC?MhyZt4__LDy|#!91%>hSYh z`v0zA&Lvw{#3_2Ak7PO3C|h z<+?*P0UfjD4c;4@G)^k?aZ}6M`?Es>n_|R&H6OkF6u(CQ>}jQwN-SjjqGM_Lxi0QY z6gniO9s2xF8=2<}-;J2RZf=YphzWRkv^zd@fHOlgUy}7|OXeX}Z%Xee<}>qv-8rL= z@H+Vsmyi00leFFc`CmK7S^GEW;JS_#zvSsN3Yw4CyGQ;OFW2x}zbH~3q!1Lh24#M& z<(cmnQTMe|Wy-Cs>xLDMANh;? z`M;OXn6bF=@)Qpd(gah&4P^qZBP zGu>Z}7r(tVmU)x4)u=wRxarGz#W(*9o1H#vNM`x%{T@EH0`L?>6=QSrb+*cirs1;sYp6);+o8q9>*^cacDzq4=y(5cCh&30v5B!W+@@ph zIL$kW?QZy-2ulN##BXtp>rbxwS;DLJey-s5MQ+}v7GZ*eO0OF<6ytv|4!Zy*t*Ut zTen)w_}niMWUd`MX>!*p`{1juM~$7dGkh{VbOvpsUUQMQ!T)O2S|WFPRgd6%dO{j} zziqIyb^BT1jlOBxKgrEB+3z)#eNP|pK+iIrFJpG{YO_flRu#18$$^%%=JmN6&Z@t6M?+VdevYmETwe^)gUpfGv;E4omC%CThc2IEBO1K2qB$}I1`p;qPClp&78!-9xXNu-s5%Y@fB>giuRiC%Myp*F)t+iJL6;yjX_1sbA-)em`zD;K$qY zD4uz;nYvD0LJt3hT=$NDZoI-i4brtd@6xKIk(S;SS68ll-sNWUvOrdCc6UJRENz+) zy7YTTA|=GZi1-)u*{hYCju#af9mDn5Y&0#p6WV2|u+Bd@Q^9X*@c3uNh>SctUz&HT6gW)Y|p1oPu(|n&7SEB{9k!p_t>Z6y$%=hw!g{SIris$7A6g^ z;dj97UKguzg@O70!7M}F27Mzfv+b=V;Z(~4b@9u@#}`}HJPHdzTkpo)QrKHT?5k;u zSe&M{baerJ3sNqA@zF1I=!<_rXh@w`V6$I#(T5SWMa~-gY&y}w2adiEn9aj~-E_ov z^61AOmdP#YdK<`J(!YQFcN2-YLG?Xjuz_W%U3uW$i`jvmXS?rahmCFtVI-=jm~J~N zX%sm+hj_~v)o+)dyLhPUy{b^9&hY37jElB$!_7|(S20@)0*>pbMc>}w)IoIAdUGJ; zUHuouG?9UM4{l$9Ppgj`qrzQv5pJ38+IKQsi~rgYC;8&94Xq#R2yaSN@|SNnX)J%e zwR|o4B=w(*I&RkYT87uIm8Ua5?Al;{CRwfQK~=4di8*NuTXFHAD9Ho!>0p20Eeu-sX{V)Fe3EqjUBFSfkV{SS;u=b3~7f<&BFAsuWsa_Yj)Zp4)&Wz7|D@6&6cB=f_EwXAX8ND|kpIek0-d-j@tO*+ROiGW2lM}~=imA9&mMOu z`R1^WbHOu2%Qf@Rs$I!}mO;mZkot8+==%d)a-TuN{kT;D z#?>cF7OL@+EH4^`+4kktMP``DH)T-Y8a-22E4hb;=DpoU$;@!QwbwwDIM6BMe+moE zRr%WO``OI+z*TWy(X{v|^`0ugA^*m2U82dST#zo8RaJTqnOow+-W-vgbQH}b^{)~U zDalD$Xn*uO(az)9dU`f@oY{##NWY(Kc^_d5rdU00wb$bp81{){Em3ucikqpV!18NhQ#UNEdTdg;bqP8}MR zFKMEdY)cTV+a|R#DBPGYW|+OOd)YHnHg+d7>Y!ZhONDF8z5$w#T9=nuj8jsn;gZx{ zexrq)c4w`ogIaG1pC|a_&K)YG0~%$Et&SLP$iFaakT6=!*)uzJdAZzWB4Whl%Sx{7{^m+lzi0Yd`o`&JMD0i-IWHqfBO$M8X8!3#2h zT!%c!ogaK-0XGM8c@XIsDr1e)?M=}Hp(w@*1SFPVB&h|~E6orpgHU7*C52K2HEm=T zV}5LpWmm1q?cwW+EQ(v9R7^s#Ie?1&s%E4d=v=|T!!AdqJ%g(uV+j ziI=9xQzq|>wyF|d%*80nB*0JQfQz@7L_tw0q#DR2@c;`#FBoV)gf1jy#r33W8%;i^ z)2==go`76srcp;UQIn5Q?uRL~!ErDX100p>gRrqG@vrloM*%(5a$xyS7oyGJGEEqM z12=M@f;tswK9NNUjZh_LfiwYGD*&CY4;lVgHUM`}*vsV*H946LXe^n{R}sNw5iuYh zAwc567D7HoL}ctJQ?_e`axu_(BIt<%nFZV*ooFTLFG&@3(*f;SIRi3L&iwtbRkSNZXob_Ti9)+P3kgG{+(QWVL1^YMAYmO~78hc+ z>dn{63r)fS$mCliY%KM3fp~@HJ5xf#80Vy8gUf6Yo;`d)A&vo|yHpr$B_(-x#Cu^d z@Z<5vOy;o?9j9%>Ln;`~=(MCFum%y;vDtA8C7=s3L0G8%R zCl#v6U@?!?3$+7L#WIw&MJvFLcw8?}OdyjW zjR#A0CSx2Ml^a^s!>;P1)LemSJ=vfb8_j~48%7WW;7^9VRA~bNl4<~ghb?upJK`mS zbZ(K3NXP;ZRZqtn^ewvFp?R;kXYBxB$nhQ~LsH}PglPXV_-JzF9qJhXL)Mb$;8QW{ z<50YC;p!L~aH~N?GD?83(QR#uwwJI7B3>o8a4=U692m%0F34#Cu{MQ!E;45ye zkpgfD+F~RQKH0j|0DB>*#RcGerodT@2;xpQP|<==FWt;eCP`$%m?1-4&qsLH+FGLl z+6L5^N3SA*c8P|u)&WHfMFdZFyN$9P9D#rk3Y_V7^8oD4EDiw;!$}Dhx5&P|1A6n>f+wKi{wFMk~ONl!zG(1Y` zTj$65R+!uOoOfnsiuuU--pwfbzh|Uup#ATKc7uo z>qeeEFl`)HgceuKAotxb>ofPVbLzXFjK8#9$MlXy)CM&s$Pz`F`J1gXI$@p&UgEg@1B+`>xEuhAJNE19iq#Q9-UBDR%$d5xZX)`Q0& zm#)PuQLOp=ep1z{U`KTyUTU(jL8{4`+tQb07v=R>eBr!erh4u8*s3Wc>(*>df?zhD~8#R?)H~qg9+`-4J2R!QipfDSh%f!_WPxhRNx^VBp zzK9X)?72(UN2J#Sr!^V1V_R&4jQ$HhWA-7tpdGd4?tz5z7m2i1}2beO~U$Jw-8CHGg+cgZ!|A5zexJ+3L)#r2jix4oifYZq`H*O(b3 zdHP^n?%(PRS8!ipfw;b>YM^0O1^+z{#!GJCx$U@cj&|&wof?{}95qv;7pa}(>+b$I zzGyt`p?yN^fOC{l`+-s4ME{f9D7HtBHmf#7@NYdGkUFy_X71GbQ3uvvSiPC*hP%fZ zY~Rky@cPyCDB*PCy(HqQw%R{V2LpsB?Tj_Ps9l{^yRs5-dA~7d?$bnmU=m9G?}tE0cK-KK}u;Y?BTCmwEJ-ECYBzK?x)4LQK zm@q+jGR{uQRWIob4I;RECSN6`&7D**I>rikaa^rE_vF!44(9Hu;!OGO>6G^z#J9yO zSXUUCt7jsfj4R(j7}y{BwPa|retdDnE%dj#NsOxUGvQ}pqgw(5qaKSOT?>1k^Au1-caZ^g?o zzg`!0za7cd5DlG*H73Q}A#BF-7q4^r{(f|M4@Q1U)8n1oSeU64Hg+*Y%}L?M!CtKh zv6G4A$y=i>NTa`TZf{z)S3fHW$Ct5I8QX(FTa{J3PMvI7w|1~X7jb*yXGj7YI ze;Z^^UYfUk4r_>u@h`i&EeE{Gakw}QjowqvrTYzz<&5dA$lN6#?OUF)A`4Px(W#hyU12&?wCx^vYIp_A=4M_A@&wz)0rTW0o*K6u>ga7?dx^wO^5 zm5$HrgEROsk83{NKdGhDYv=cs!d(-5q-?Kp! zi{;V=4I5G?Q>`&qR4hG@Wz^}FuF#DOYkc~w>0PtVj=nPGWw!Uw7(W2d@8Yoc8KI5H=bSbg^^AB{O1SgXKd} z7bi8GkeuIfu%Oy?CH2+GEwSDt&y6@o}ox=Cp z45at&_ph)SqfeH`UCQkxSE^Lf)<06V9D1TCSRaSGX%=9XM2+=+_PwEbYmj9~aI^l) zty+rtB2rjAGkdc6Tvg7D>ISmc_K-9mx>kJSN&8E0|DYzDu5a2uJQe+#nE0;LH>g~8 zX05;X(RBu;J{iHArH{81%^|Pc%82YYn^>%V-R$Acok3MSg+@lE%kep@pDT3^vP0?T zo6axnG_0zgjpu62#hsAF6gN1oYz!uQ-@c<%rW6?rvpj>8nkD{QFJ8(RD(cv!sk&#i z1w~eg7X7J{$^MQT?|Pll&5`Yl_rg+fYR7gvb8dY;DPHCH?q9f**i~;ak=$@DH))@O z(sFK_-FEZ48lg)6qZnl^NHX5=axOB``}}REKpO++C3|I|?>+=pldUsS2Q1V2y#j9j z^N$cWr%ySXpj|v;BJ)&Q{7-ngKXMJ332&g^3ERl@;@L)KiBkF4-H64WPOA)f_72O| zsJ`)faQgzbkNAxJptItPa?-s4+ToV7W~%!yrA&63#I(%#ZMy7WDW25M+EaUNG>5jb z5_y^ZN0L=t`YcxUt;vjQOQ83+ap5i9MSV6nF=c6SjgO1l;i0$xeRZ;E46r$@Owg9q zy)umRmwEFtBjUxMJ|;>;g+wc)gYkRCCq8?F;_vCXKgc0${7~Uu?6B|YuRe-1?N0ie zm5ub>N50a;CWLP-ey1N_KCJsk<;pXy$KB zYA?BlV#NRBKjNt-)cYJfxmW%+xTMKZ*3}SpFRcI8!|kg=37BT;(ucpkk8fIp{q(5F zW<{`A^G<4B{K30M?dPHx#}$^0RM$MdS(tkf8~W|Mx=!Ve{o&Y;M{YLHa3ZFwsY$J= zTaMM2-nnmX*RYyz5&59&lw&=8|F6791^R^WKP7*Ylf=C{8-s+`PS$tmUsBqbje&54?6n@&2YG&hLAzT=NJ&;*HbHayiomG}Yo9 zZTag#+|(L#S48O=zfCtt(9_ZU9=CH;Z$<6CBm(DGh=Zv~$%6NDN5oI>ts%Wtj=E89 z_V_xA?eTUdb3^sPBbEIXcZS^0&y`^-Ph513-EIvvg|F_9IOYvSr?7!}z5rXilyCdd zS5n)eIJ$&;=hHW4f2#9{hu*l)EiF{)@=f;SBKOo214hVUf0;eAV{+s3nP18;+^!tp zWh2gWG`G|)i*Y-eB>Wc@b+BBC)pB=ud}D#X?7u17U~Q-M>$KPJk;}=4#BL6it1om= zFWs9l&iPfh_Ze}_o6{AGex#+}_VT{NlKPoh4)y9(8mCLK^Y*s0S`#fA>)xDo-bCFp z6I1!Wm+2k1hHD*@AN`)*?|q6-aGz^Aq?KHpef=qlYINPlzxoiNihD2(-Rtq2FI;Xd zUd{a#qP!Ea``Z0g&n(dcZ~SVntsZxu6vecZe8 zdGSy~>gMDBPB*S?Gs?MzchB{H8aoqi75%E{-N_^MDc@B89a>_xcJ4-Aze%LU3zH~e z^cvZpH1U9@cE?qW@L39yA*@%6F?>15&gmtP2%5Oj2|w%X=U+&Avhr!GKv)0z84qUN zeLe*=hd}#VZ6xi_?jvQL0gR zHm^`i1Xg09owOJv2Le8zPm*Dfpwf8&CrPVdZLCncGd@qxO3D*~T-KgOBg+P1ZH!q= zr3dE*KwED|yD1)1hXllu7NAep%n3IHY7ix`p;-rDNf6jzP;}ZnQwhkJ2!Wx8dRfl9 zcY&cKmBa+9)B*lCUl9i0kV*#~h$#|75rd^7gE2lNW`WK@MIBfi9|Xd)fk33tqN4-s zy#V`4HJ*^{bKY+i>W3ToRsveY`^_jvFwe=22j(t%Bcz>^0O)ZXkK<^YyP zwLwDxIzv`M0C??UZYY@|5Wra7Fl0b@7DO-qpS2Yd!E-E&A~k4o`MSa(=!#0fZ7eW_ zZLDeplx|V;6qqL${J*9;x-^yu$;Yz*6|*FBz&(qFFo#zHgMx+V0I9M&%I(%K2?&IJyKIQ6hAl-F?oypcV#=P~!U|yjLwgw0g$pQh+PKqLlElN@NbgZf3 zyjg@-+**R3w+=I>NuvbBa z+YL2g4)CSTXiG~=uxbeSHt>v*g;B+jSb?aRh3lo%S&11_z*>S|SPqTlL1`%&hVnRU zUX8REjfAiPD3>Qf;_PT?%Yj!T2iMmFO$t?gNazX? zL`)N;vvg91T|vgUye9yf?LG+LS~FqSj{tG3S4J-}qlZNVeK0mrI=_Ypy?Gk58S44N zd@>ALio)%iSRLvC>Gr}NE-Qdl1*9n0lx;P+%v>}~DZ;j&mktMmqR!KLK!oP(IIg(} zioiBNs45RK;Nl@Hh0KRZk_u?}0ER?9jzyWO@Uvx-i@Al!`M?-rdDvpa+ZTYCh%E9H z&rg?n*4KNXppwiJgW{Ua1!b`uB4aU_x&u`N90I%}pvuwFt(T1V0c(hWi9r+?i1-{_ z=MaFU^)L;Ih564xkqCn?uqO{SVb?-3$dU)~3)A)M!CY;rG!BhrT{sz#i(t2u#f4J| zWkyX2NmSqJMMSec9&fZnO{f-Rja zk(OZa96quLVM&FRO7L%!NF+GOfIk7?H>!k%gZ)UrA>pI}MhlUg?#^-8)C`*E{wCRc zKHPJN-*y0T>+7MAn4`}qlw5`_%=|t1wC_X(;@P_1}IBUC#M&b(g*@9 zW?GZSqw0tl8dS9BrxT#5jE#5W#mAEIUd>G?phEUjW`o5s(LiU~3UVScsNE zEFTJsrX?snh~imrVPGgXRU_( zfsm6-)KivaDMkU|$hM#VCMLjKDk4)ZlgZT;4HOos*yVw`8Q@uZ5I-A^HsE{M01*Uf zr4KldU}lpdwkuWclnmCHw#;XVhD`4Tw-=-=*t4K>!9S@e#e4OQ4GI>j%6|~=loY#4 znpzLLpZe@&BfeR&aB@r;p%L%9J>TAemRw0;;7HMb7aNaLPaL~H6k?byYdn7Jfn)NwJ-E*=_hA&>BZ}+$ z_O3ne8ikX34e#IeH8slrsCJ5d73H!~yw48)$$Y8a{k@A!zWl;3T);MeI-Y&t)NHZ8 zB2iuT`)ca0k^a!Kk+9eht1n}nm5JJ7-eyG8as!ML=ChO4{%>z9x2V*JGjQ@Ov!zNK zm$~oli?b$ZZIEV-DOvC~YX!G7bRYVGvDtxoxQ3BX9$Y6diO6b7ep5}J+IQGH!|%9k z3DM1woQc#qe@;|WlVDx@8*|LY_uGe9yvZs*;dX9jq(%Ff3ps6t@m$cZv90;X$bk;U zMz0?qZGENnL{O}{WD@)Q?j!wG>uvv!qceeL`v2p2{M3(7X3WuL*w}22rBeCz%WPwZ zV>4?TiDiXKh|(R6%p_);O;l!+p<*FdhqaZX%hi1#D&6V$b@%`HKOQ|wrS;vu-_Q4W zyh;$`S+;A+tQE7JzU^M{dsX^+U7z*I!?Mj$4(SV?h5r$lKm2FIr;S+GDPJt)=FTy2 zWUp+}eKDQiaw5d3?7&sWb=kqgunRKTEg{2t@kEmqHc8nx7lgJB(RB7ilBi z5FfksbXJFs)p2~B@bT&5=9Bf$FY@MwF(~t+>tEx|atjvOW%_>OzllG4KQXT@EGH9j zEwd7aKt;L-G@1E-u0Oim+1&4k=oWvT%@gab^OXgXruV(R*9oKFb1fgJYT*7y{RqUxd_8kz(ZoEDwB_-`tuZHOJO2KZz0ur|3~9g+4&{Y?9xPpS+9fF(Z@O%g z7XIIoXAeUPkjojQ7Zw9%`5c;4o<4s3ppnM=G*N!y=JD==ybTv-M%2Ocx-b9QX4+eQ zNPlGRP=4f1zlNzW^P?xvzdXBP_NOuaYn?RF|Li$+%w=Rj$oT89GrjDW9(=Nw#hz%`b0Fa2)cx)=7Y_v(vGFT^=6EcAdUey3;jcr_ zE_L_alJLLc-|151=z}LC%1Y0((w2SQyrs={_KJP$+d|K7T-6s&q8m989h~&_X~YLp zg}Pi*e}CI$eHvc|_N#5x=IGO*!awdoAKK7E$LC3%4SYc+<*NK%{fp}kYxaufQSUdr zJ0EUmYg@9-TJ%J<-XSk<(3%C}7syOWQbN?9|MCYAPts~Ouew3l2-mpA&(2&lgq$1k zfO*dI#p1@bReI?AI0Wt!1 zpa?A13 zlRx}C?F{Ln za+4iardM(|#vSapbL%(Rx3o4m9ie_O2UCC0=gR3uB5Lnlrwun`TP=eJQKj4GR&)_W zxc$o`{4<{X=;2tzV@I@${t1<5T(6pMc{aUO)cHR2_~e$LABgpfbT z(dO{oot`0P_wO5AWnbVINNKK^jYjZ0ZO`H9hT5BDzYpNMR`@tv|6Ct^d)xEm2H)ys z$4U!Vsx7N)bPw$}-`)I3Q}-%7tzdKb`I3joQw`@F^~o%^U6lVUR=)F%X+yrB#8*FT z4|CYR%yZz;)-jgg@{E&v4uyv8xo-V6e~ql!dc#KX4q|+v)NAH1&Q;|i(LVuY?*=IN z4TPSd(q{v+U`L1JMv6*(=3DINum89Marwfn8{g+uBy~qe$!a=;E7B33W>c)xGfVGl zM=k6hZF_cS^Z0{c@w_qCBSp?3*S}XdFPp+|DLX|#FIXO9`7?ge2+n5TdgrW055~0= z)uiiZMw)PbvYG1BI(h51^luowgygoNsl)${w>3W8sgrZH9OWFD(#YJxd^f%wq99bv zdeO=qO*67iQ=Zi!W}8$_&8)E)JD`%Twbf<8gO511zs*LXn}U@=+Z?to_dC&XM-xam zI2Vjj^L#cAbTQxBn8ml`OR(=;1A8tD_h&4`B+pxx7Q1ov zD-_G+aE#FGA4g)a|M`2ok`9AI7@cpr=HS3IG2 zg+%XNy6?1M(7A5r-?vt|p)S2?tTeM(c=(@F4^5)b0axyCoPE%>|H))<$6IGgSNo6N z!d6aoRQlX}eT+?fm-{oudLi7JmJWYx-2|~dEhiyB7|@C09y{?iZ3)>9Yq$A~t@h_n3t5piLBW{eJmwGmeRl_lf6a7geP_IuTs| z>_egJo>Oi64_>^eycjY#kai+kzUM>#;_KIxUT@rU@Y)^DRW`i!$Gok^JTT69LrEJX zwBT~;;O__2zx<3L%!7`S{}AbQ@75hBgf^VXE)9uLU3`CL(jsbL?bTVQBW;!)f?4jp zx2&)C?#Z8_+@!jHANU3ytjim`9}%(i4+XOA$&$H@B_0BUtMF-9* zg8p!hv|Z~&_ML@vKzz>*eYKzl#YD80OYg(+&V{pk%9e2Z12U?|BVnfht|rD^in&|6 z)FA%xTb(EqA66ij=*4tp9CeJUi+aB{>Y}RMK#*44Mc`f`q;rS`vtsllF8IN8SZv0Wxr0!@vVHaXWR0W9Qm+rl9 zbDfNe(q;T3G%NkQ^%+VLQF(q6@_Bh}Q@*gwhP&&OMJFf8XnFWyrpeo-C3j}h+)H| ze)_68vn<}bGrX18w!8ng7?K#!F^e$!we8e5!f*FOb0_yEITp>B!D@4v!ScjHM8Z6S z+QZINmk`2Gj;k?98AVv^Lr*CH_B^JVg;c3Qu>_UD=;7B=86Xl1K#9;Cv07@{!}nn_ zL4^Y-_rOx7@p(zW95WD`q5mG1~2g+2&zzYIm9|WX7 z9YF^R3Si}wi70xiFN+EKO6UM6DJNxHn2n|lFw|%n7m2B9ODNzn^l=VWUG~)&DU?j+ zrGSie55J}r;Od%yNeYR1^j#zEKtJ+R3Pa&-AuyHbu$@e%Zn}*2<+{$X1RKYnf8jMAPp+! zB`DbSMLND~FD+WjO9-54z|bPBy`#+Z;IqLJ22}cfoV*$-9su>gJRi7VphpbsoCwgr z!9r4yXm%R|6J7x1&Cx{rT2O5bbPO!;J2v`; zfD)Qa;AHJ%0BNO0jgBz!F}24EhU%kB%z)R<)j$Lv8Lu%BYX>Eknu&*5F0O_?inc#Z z4Pcu1%8ec0oSXVZ+jX_EI}ZES2K|ZVy>-CHGpK8>l~K~T3bAk zQnUfYM$v%02yD3e0W}EW%4+M#(rFKxui7W2sTK6mm}$^l(D#c6a7MU&8M8(%W0?V3 zpk}&=*|Wq|&jas@HvzSCm(yCgLnU@ln5+)zfl(xqlO(4=X4k%iB2%e>@s8K;vkA2F z(rSl*^aGG~srDgI4|bDE!_SeV5=d~!Gl2zvmZA793;^uaNd z${w=4EII^|D@|>WC>L|J~>6M?Ijp0X904wX&k#2O3b?j zO1r@U%mGno8k2(_;PFyOHU^!2o+1900trfgl_1kjp(w?`h6xne$Ve1VD<`3{ErfMY zGT{Ab)e`W|nYE3+N-%c-o0y2CP()-*O_KzSGCYxh?#W5@?u6&WgOdd)M7pI&O#~TW zc9qFU0bqB^nDCsDTw<*Oh{wQnt3iXJBo=65QSjq{2NdrEb&aF@)_Vc}BbvqnCo&ND z2dz!YK{I8)0}`+fO3`&#sGU$Pj`Ia_yap_YX|y9025RFI8I6n{hK7$%Xym)svs)WF zHPe(yA4a|=5m-(+>_BTA(gWrRbWo7cmjTQH3UpV4Z9q}PT+qK_E39#1{`+ zNc;>DX3!W_y$2MdOO3F(keyn^l_wm2^Pl>FCngZOi1KN_O>0;|v z)a+T?JzB~j66=H1kbw2qZtYQCH`y0xbJl;?d#C37?smr2D8Jct$J}g6?zDT%n6Mgw zer;2yI98Cz3Dx)-JSHy!(EnI+VC+f@g4;czQ2ywA-`Ry*Z|mp4rIQJm#Y`{Iqn=-*>|-)|T6+ zcr1S{T}66k%0fhk?7GXW-=}-tv=86ZF8FM?m)V!KCF0x9LwO<6MI}K$7Jb;gG|_g` zhx0#O*6hYg#N<`+yb);|F2n3?<-`_+)tAAj=Np)57ZMg`Scr;jtg14q_$Y~MGrePc z4}EuW<0cXrH#3dvFrIc*Ri95f!fiQa{Px9?EWaE3_H2a&TzL69TCZ6b=&?nekbTa? zZlj$$WcTUFVR(h}_6J9(s|-*7`Eh9^?0&%V89Z}NWTnLpv+;=FBX&{Gaj*7<1fBb@ zE2`s@@4pDHGVN}09U6Y|J5)00e&->@WU-CL#|mb*dUc}(#WH>5NDe0A`@EHVL*Ane ztMrfm3(iTk46IBbARH<1#E#F3m*c9Em@OJ3Zv15*$X;H6dj-lNwW0d&cA+P)@2GM$ zV;Q@0($kE7tzVwC$kcA*g1xr&Yqpa)bGlFZkYZ9olAbRNPw4#^_~3Bw=ngn)X8+SD z)3GT*er4Tf0{6-(3UQBP?zQDd3{|4jF%`2eR+7c4)5$3nQhfZxW9Kuk{9}v%$c|JY zJ+>s>wd>BlIrJjY{NR~A!P}mvu4W0Tlyv+2`BpJmI|sIDybomFoWFaAlRni&X??li z=@R7B7Vp&Gwph-w&eeH`DVNhI!~Q3C^56G7u#d}aX|agy*IEqipBL!ZIlDBc0`7B;w?%OES|QSWLYiYTQ-Fn`m>a`#g{^tjyX;&G!7W_`_l0Wc7{kX43hE zH45d>cIB^MTbF0N2s?f}>~@6J@nz02T|1VYO)$I^l8MpJnB5;tyhLwbPt>AEu?TFYkFobS#zSj##u6;*@%cJj7+@8gmV|0C^;FD#Z{+G68EE3~!6=>;pO4RyC4tXb$iPVw)Z^`KyO@~us^ zypkRTDJyH0UbtwBGdUYr5I!^Re(YQ8i3+Ax4~<8C+J1x*zoR(7NgmzXlUIDLA?GV% z-ok^>HEk9C`6m05lw-r(Ws@EmvaO9DEMmAo27M&a>k8rT}yY1 z-W3Lq)vVsdRs9?;t8BU1;bDSb|9C1^z5_yi8oOabZTH**Luztmla!&fN^QJ(gz%KR zbioL{=3z1p)kCH>3Lby(L$dc2XLnz$Nl1r51?a~QJr|hVFpS`r!S-rRbIg6-M`jsc z7`NynPt~8lt8O-Kp3-#hm{G&lgm=%*P0gv?yw-EAw&7F)d-E%w7ui*Za#xb>-0RuZ z{YAB-bfpL4v*p&A*Lod~n4#7koyZT#t@3f>r3W5dnjpH$gI4<3^s>KiE=s%Wb-U@Y zx<2pKdVj~mmNS~*7?M8rV9q3F_G1hrAnSaf>f3F$)1z;B`y1}BQu)|-Ak&Ye%j5n! z$k>~LNytU}RE4Se@Cx_t!F)F== zdGFCKIvkS76?t!YD_Ifi@1UN@8{(Px=9W5l9gg%?qHkDJwb?HSPkXL=xoo|RTegbn z<^(%A(~w6xBDK(S^uPTCCa7cRtdau586)r*yZ$J3A+Wm=d?Bj#;O7n<<_%vQe z(2}VIm|y=@o#of>!K18>3-nDFt#fDhnnMysS;6Zxe34 zN_yIDvvXSDWJ-~P?9F<7<~8k_AWl(d#{LFRE&kP-`M$lEmn9UV1pCG}Mz<^G2PN+M z&#CKI@;uuGbJm@0ZIAv4r>K^UT6&k6nVPt{$V?lL{(Bu(^y^AXMO?+~j74_fhv>@b zisAFN8?G;jNEqXI6QRze!zRVYtPlHG&?W9026s9ReHs_?l7_=ZR|H-?RJwJc;dt4V zp!P!pTek(j@?W^WOE-oM@?>PB! zJ*K$xiOU0hQd;Pw$UJV!{_$~mlMsKhM#OXtc1gUs=vvO^4L&(h2#|V74PoB->!Y)$0r`DaXOc>M_2V+Z*s#S z(XY2XbN;wf8Y-+LYg$4ZcM0~@s9Y2GP={xxxopQlQ6ZP(s9)CxW}*_@xvH)50ITA^ z+W*^q?3l@t%~XqmJ>jk~C*Cv%Z-V9bzbuK-KgD;mFRpWx2CpoP+ZyTrs0DjeTVHb( zrlhx$oUYcd+dxqx8-?u4Sw`5k7D4Opo1eH_^m*k#$6ME)O~mR|NY(mfvkPr&Wu9%X zTK(K&T@Hwb`dW67JodRmk1jp6h+Z|HRQUYHH+XW#qhlR@>Xhp)Cfa`-+V3BB%v`9* zuvC&fcfW>nJpxsUMt`?dn4*%t7@hOo7<=vw7dD zPOqq@4G$Aso-bO9bw^SE9HEz8(=pEHRlBGBVooK$S(_+b+W#gXAiQv>Hv$snV1Ps} z_pz#3a{Kw%lGUW|yTuxKb=RQ*Cu`6S+1vN~b(ZCgko^~*FYajYWU}ZEb6x>tmEb+!e;j zWW~9OMmslh=_l>EoX?6))Hg>?#U?zxUHfpM%W(gB*8BD8U7MU&*B?xrdD{=GutA6 z7N7UXbYm8f@4g4G-@Gllh!UH0`0CtH>PW7#GoWbQ2DQp^&dP#;EJJ93b<5l3`@G(q zZ_k5Xb!|HIaq3TE5C0wq&$FE}*)jAp=d$=&4Y%o#F(eiXIST1(c=k4Qju*_~^mlX1 z@S7{+53PL?hMsKS(A56Lp$g6*pUxfg>NUiC&wl>5SL=dKeEOore^)$ybJ0|j>}a%%bM206*Xb89f9Q-(oEbQF_7aWVo0GI11j0;xY2o-I7ENj7a&?Wo zWWK@JZL6i_K-wE?>PNyv%4PPa%Y>0jg{fPgh%HNN9Nh5@67b$yIZ2)%(M)U7_`nYU7F+@Ns7a>1tZAucmicsZFn*v{0yH<^*(~rt1C}Ja z83ACN;vVl16P_A0rN{s{P|2$Vo<12e8@T4s!MZVt7Py}@OnD8B0rI?RDTuZLBq2`? zM6pWyXeNVQqhLsdQXsjNK*3gGxeW#N+C&OXV`rm5{$IERN(~zDHzOsQO+YxM(Ai~3 zs39@}6es5g>d3Pq5`VvY|Gioo8|fRc|+&^$f>q5yU< zbtD$U6!Vn&9)6^{#C2fU4oX2X;o0_iAY|Tbn+MpJoFuBJ6btN?I^dyI4$(@Wcwern zSqcsZWe;x{DDfIfBnq^j2;vnyfQjURW)D|=e_D@EQlu>uRF)KV`9%ZkpOg*a@ERY6 z7<7WrKp8U^p6yEtHh|b}387CUkx^k17lcqOjuiBOR+hxXL?F?Fif3OA+r`!z1fj`* z)Yl&mppgi$cI44qP}yS&sCvNB2olahDT*-!^lX}fp|-QdsFggLo0&+2o=%H{Dih6tdHjgBZP5VD&irJhJKa6^M0AE0dafe*$Gi%R5&D`>y~3P_8o zNuZCWA=lXl$5r*j8^(AaELa@etpX;6;++86CeDi{o?!R#eor3EEP>@qYK4M3o%NIodFidQ4ifOrr% zTjpY8klF#_1c@vH*a%?##*&%QNDU^Mg(QG+g@M>BQ)wr_(1pU}grB-KEiEogqRnVC zT3{4swB2Zs%~27c}u z{{ojKSTDEW;7dZnl_Sr>ehX&H^3wd{g+9hIRpVOS?$){RmX5;MJe>ii+L*4QIUC5mCR<@xx6T5r7jrw!8`TTo!|KF~foZ1iN zhmYQ%4?dcQsr_i`;o<6K7AZ;}wp?~(-44d$lt1qZ`wWVWazZgVwM;7baAmxw>;V*V zuKnfroX588b=$f)>&`iiG<=KVZr#F@I*z0uAFM%mt~zl7mU}+^&RtXb;aRSUrorte zkq*IiWM}?utz^zU9lXA&q0%_j0sawx>YHdgHYLMuSGl#Iu*1TZJ9PZv;5_cP!mrm$ zHhb?(Zn!WH8rXC1gz&W6p+x^R(W)oE?&_a?f1J`<*?+HLGV}PR#)RJ6mx?dPjO@Rh zmb=KtI=cT=Ui8@|!U^@kcr?rZ5_tnZKAKhjkJ%b*+JS;|qua$y7rKojbiVVCg$G){ zm);p<4}DHjUW>Z2ds6XgEWfa&xRCp1q3Ew`HCV#twHuvs>u#4UyBaat zwmP+I$zrFP>`51`h1aJmrUjh0<;~3S*(Dd_Vmiimg`T+9oQ$RC+LYSbsW=?vQ=h2k zv@}m#arF=ET|>y;+xV(W$|kF-^KTTB+TE|_$FAPzP??e(MKxjzx2-g#H`$@#l+8=| zRtI{1u!O@WVoLw~-Zr(^`9b`kzgKQD^4A4qtCYFS>rs}+!k%hhYv|OdwX^LF)*V0E zd)0SURoHMrboKYJ=GUQiCX~zn{?I;g{n7rm*o^D4??D~b^4}$HyT&Zn_VL=je9mUN z$M1VK3?2nqs|o8~@~PdZiP91xUtBRz6A zkh${)ocE_CoYt%pKXX^>2=`7uerF*nSid1qc>hYgr?TwK3vzYD?kq4n$^rYh71b?d7@R zvZklXWD$Bl(f4ZiAWL|CF!|fb?sLA&fjzsvB)xXdqTJ1YuRgoceq65pr*H`p19ypJ z7ChS5e$%0=stvbp^@)hRVa`3`HPqI(xvy{gZ)i^+pKE*e{G5dn6O;RBzn!54VsgcV zYC%D~RzNWcYJ}tO&*-pTMpA8&zuHDjyu*MeKUhOtqQNd8}fNk#Q*bH};=! zVxv1AHtSv?^^6NdmJLs9d$8))VHXbe-w3>qY1x>wHuZ!zPOuKXX?5GeS$>?>n}4Hb z=g%~%bcE~Fe+BryB7}Uj2wFzR*FnWyd7#IP4;7T*1z3IDqN z%)B=~D|a;R+)Z0J-18Uy$&W>Qqgu#jc{^>FrxGpeuCH9Vv7Kg?Q4CqP<&7Ek#4T3x z>u>aMC*QPXEAZhJb7mbLc~t)Txb^Dnmub(>CBJ#VlKp4K%i6vF?<^}t^qTLIl;3j> z+oFrc3yqtWdD(4Y|N79+`BH3=?fmlE?^fgjTE$)Uv%i*@=Q_9>cWb_e{k(E*-m!Gy zUQZ`TwIJlLG`Z2=gZp3ZWT6rY!bXe#s?QUxhr!sikq`%?-Oo0*xG#+ro@%zqBnTl1 zgx}9|oTqLHR!`oKyIDpjQ?u0&zmImh=h=5FX_dp=}u`t>|^?s zl?w(xHu+=d@tblm`XkSeN{^bDQi9}1<_Mfi&gQn~JPrvKYle=iGixyJQoWz$*r-t8wDZ%&tZu9%=l zr3NgWxAS};xuLeAY4bhOmMh%fl}{p4K5m#J`Kb=m+#72VdwIP?epJmqwsiREY1L7~ zbvw+D>zd^sJ__!Czt(yhRUSs^y#JuAu+fJ7v$OWeM=!UKy*anTo^WJ&$5sS>(!E=H zXs5-~%z{dbt!ti*y5A{1yf~?E5!GAGc)2#W<3$Fa{=8>J^R5r6aoa@dnd~6z?yWVoXXT24S{7;*N2Z{@bVUOGH@k8tCoQy6a5plS`DgLr@0Nlr?~?E5w%mAS z+SFUzdsuz)R?hE_{sV79Y#L}E27>hsciHK>)|1RPSdW)q>i5){P5qntgu1i>KEYjg zKV6&}uqXz4WlK(U7$g@6!Yw(B^sWzG{rEGU%@eC|oNrdEGV@C%Zq&W;w>K9vgndW# zT5$raCRtp8SV5wf2|^e6zC&GLEzS8>vBmXnq}d|`J8W3$)u$U)olLslB&ed?hm7&( z9-bqZsW+D)$#)j)+)wgux%JM2^6|xn!7I1kR;HKGZQ{CUyJqj4?86W=~V@E-y)I;eZA?+2BR@#hE~ zc5AO>tXR`0_Dm4v*W|CGPVR_M?@-=2ZQ{L8_bdrp1AABnhteWdUX zor^C1%)U@xS@=U{#`v!$Xw;{xLw{OITq_vwRW1F{nSEXa=nGtc?sD(x1J?kf>f<3fpZz2cEp|SVtxI(Q-YtR(dzfQ-Txeh*OO=F`3HDXyu@*QEOa^5#(#ZU z6}vg8=t5oYKXzeyW=Ah;B zpNHg+PZ1_BlRW8!Lw^Ny@yf32t!>PWv*OHq@b@fDEiFR-{5de=;-3>U!v2iRF#q#= zhEek;EoR$qqq%;s-d`08R=CXUBa7JbW=LKx@U$S5{9bZ!uBpBNKg-xJ%7hQ3FVL`= zDCPCBJEOTWBuX?OUuPZ-0nfPHV(;^_?ad5y`w}}79Alu^0xA*;;)RtK5FzlL*ty0x zApu(}+W|`@@QM!tzq*U#IUOF3v9l$_EpZ;Af$Cm^K^f;) z&w}Tb+1Uzf0?muP%P{hGESlX~;AJ`zkc|^78-afdo)>5`BA2UlF95qsZ3`Z9dCG(` zh`1p@m=XxFfHl%kIiP(52i_HJt(}Q?3?xU#&+7*=5{9Pb(SYTyNn4Bq+C+YM0p0@c z0C0yp`4TkRE=O-DG40MZ?PoznL>-^qEaO_m2IRGvj`|_%!6P+LpBiyU?f_Cm=L4{s zzz55FUk0+&NmQ6hfufjt%aq!&Mp~qRkAwyvG};Ui(*Re^#at`|`%uH90g^jHis#of zA%)(7CcHQYGCdJ$&1(jhqk09#u_LIHu^v&ds}c1iJ2A=Ff{Jl* z9>oFoqnxI#obLNI@W}}9)Xs7>0IXRbCe+lRlDi;!?CWLpgjQhHB!dD9sB06z*^$h9 zJZ(Wpvd1S8BIv`iL1T0Q!9W1v)<}T8Oa!^aI2t%C;&2id&?UncN*ejR0S0(AXTFLA zaEz*YrdS`_?A#~7QN(;qael89nAzg^$|1IbLog(Ap!iIdP~hF9mC!i60d_RY1r}m9 zy)Qw12fGc}tz=v}UpeXv`i?;xtc$s!KE~819_VQ1`C@S&%3bRnt&$_fE+#%yvUfQq z*LXe_I1l-i_Aa)zAT5i80VPYXVA42o4iEt_2FhrsBC3mBA4E$%Jl49Tm#IoZpek}p zz3Rg$vYN!wM4fX!w^`}w)|0juw_H`6&+qwE#;^(FvZTJkZ8n2i&rL2XU9@{jl1dQ* zR)pK#qn76Y#o*}*F^m3<9gy1IXpuHmA ze9d3|-x&2zA*!3tBh<^DbPmJmBgYMzW2&qFk3;OKQpUg`$Qo9kn+*F!I74c|{Q$Y)@e9 zL6O%DpIo*4PLJ%3e4D)hW%V|fuswvUmWON~pL9;jJF8_0KX3B&AtTFW>O=-vV~2b8 ztv;0RdN=<80(Ue3e+yKQL?iNruHbMl@I=tcxq2K;TB|HTPIq(P2A*S3Kg5s-^8k~8 z-RWkFf(93`n898CUS^{^J<*l3Oa>?zP@bHr(k1eN12Qvews({{j|7pTlAv~Gpgy~v z=3)j2pJE;f)*ufw;cL(kkyup^x{6#ucMlaXQpq`EA2Mlqv8@Wub}NGcP#I+8?96HD z6?9`s1op1pF75?b3NY{)JU~4Y2M2ED6GCiso2W8Y1cpRUWkGDaWpj~ z+c(6+tUk)*eU-7V2t%VWHCPlVZN{ka@d_wR;H~XJnkhLZWKcP%;j-gA1;G(}-JPJ})H~1$v#D85{>XkI%=nHYqU8 zc(6w_Y05-0NavH&`WQ%H?gp4i-k2_-N!bs4x-JL}C|d6$N^McResDqSPm<>FQgH*f z0mMTBtgT>%Be?CFWEQ&{N+7lweEYxwNe+B$H`1;TWRYadl6)S%M1;F2hl$hADB8q8UUQfGjx@JUfH z`h5W(R2#32uW3>aHMEj!VgmzanTsO%0A=ZmWJD6C@xHvhveB`7R!Y#MOuOY;=r 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 RyyeE1<>yZ~V99nf@gD$yWO9B(e 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±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�gaf2rLN*pkunw+o}MAHYAp4#5vt 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

    _2^ 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'zZ$Gp-sNNhIx*f)tjo~|f_fZRK+#$+% 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 M(>VuB**4YxKP}1$r2qf` literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump b/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump new file mode 100644 index 0000000000..e35dcc2081 --- /dev/null +++ b/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump @@ -0,0 +1,163 @@ +seekMap: + isSeekable = false + duration = 2741000 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + format: + bitrate = 1536000 + id = null + containerMimeType = null + sampleMimeType = audio/flac + maxInputSize = 5776 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 42, hash 83F6895 + total output bytes = 164431 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 5030, hash D2B60530 + sample 1: + time = 85333 + flags = 1 + data = length 5066, hash 4C932A54 + sample 2: + time = 170666 + flags = 1 + data = length 5112, hash 7E5A7B61 + sample 3: + time = 256000 + flags = 1 + data = length 5044, hash 7EF93F13 + sample 4: + time = 341333 + flags = 1 + data = length 4943, hash DE7E27F8 + sample 5: + time = 426666 + flags = 1 + data = length 5121, hash 6D0D0B40 + sample 6: + time = 512000 + flags = 1 + data = length 5068, hash 9924644F + sample 7: + time = 597333 + flags = 1 + data = length 5143, hash 6C34F0CE + sample 8: + time = 682666 + flags = 1 + data = length 5109, hash E3B7BEFB + sample 9: + time = 768000 + flags = 1 + data = length 5129, hash 44111D9B + sample 10: + time = 853333 + flags = 1 + data = length 5031, hash 9D55EA53 + sample 11: + time = 938666 + flags = 1 + data = length 5119, hash E1CB9BA6 + sample 12: + time = 1024000 + flags = 1 + data = length 5360, hash 17265C5D + sample 13: + time = 1109333 + flags = 1 + data = length 5340, hash A90FDDF1 + sample 14: + time = 1194666 + flags = 1 + data = length 5162, hash 31F65AD5 + sample 15: + time = 1280000 + flags = 1 + data = length 5168, hash F2394F2D + sample 16: + time = 1365333 + flags = 1 + data = length 5776, hash 58437AB3 + sample 17: + time = 1450666 + flags = 1 + data = length 5394, hash EBAB20A8 + sample 18: + time = 1536000 + flags = 1 + data = length 5168, hash BF37C7A5 + sample 19: + time = 1621333 + flags = 1 + data = length 5324, hash 59546B7B + sample 20: + time = 1706666 + flags = 1 + data = length 5172, hash 6036EF0B + sample 21: + time = 1792000 + flags = 1 + data = length 5102, hash 5A131071 + sample 22: + time = 1877333 + flags = 1 + data = length 5111, hash 3D9EBB3B + sample 23: + time = 1962666 + flags = 1 + data = length 5113, hash 61101D4F + sample 24: + time = 2048000 + flags = 1 + data = length 5229, hash D2E55742 + sample 25: + time = 2133333 + flags = 1 + data = length 5162, hash 7F2E97FA + sample 26: + time = 2218666 + flags = 1 + data = length 5255, hash D92A782 + sample 27: + time = 2304000 + flags = 1 + data = length 5196, hash 98FE5138 + sample 28: + time = 2389333 + flags = 1 + data = length 5214, hash 3D35C38C + sample 29: + time = 2474666 + flags = 1 + data = length 5211, hash 7E25420F + sample 30: + time = 2560000 + flags = 1 + data = length 5230, hash 2AD96FBC + sample 31: + time = 2645333 + flags = 1 + data = length 3384, hash 938BCDD9 + sample 32: + time = 2730666 + flags = 1 + data = length 445, hash A388E3D6 +tracksEnded = true diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArrayTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/VorbisBitArrayTest.java similarity index 99% rename from library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArrayTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/extractor/VorbisBitArrayTest.java index 0b36924e55..b05cdd863c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArrayTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/VorbisBitArrayTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.extractor.ogg; +package com.google.android.exoplayer2.extractor; import static com.google.common.truth.Truth.assertThat; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java similarity index 85% rename from library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisUtilTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java index 6dfddb37dd..15add339bd 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.extractor.ogg; +package com.google.android.exoplayer2.extractor; -import static com.google.android.exoplayer2.extractor.ogg.VorbisUtil.iLog; -import static com.google.android.exoplayer2.extractor.ogg.VorbisUtil.verifyVorbisHeaderCapturePattern; +import static com.google.android.exoplayer2.extractor.VorbisUtil.iLog; +import static com.google.android.exoplayer2.extractor.VorbisUtil.verifyVorbisHeaderCapturePattern; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; @@ -45,7 +48,9 @@ public final class VorbisUtilTest { @Test public void testReadIdHeader() throws Exception { - byte[] data = OggTestData.getIdentificationHeaderData(); + byte[] data = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "binary/vorbis/id_header"); ParsableByteArray headerData = new ParsableByteArray(data, data.length); VorbisUtil.VorbisIdHeader vorbisIdHeader = VorbisUtil.readVorbisIdentificationHeader(headerData); @@ -63,8 +68,10 @@ public final class VorbisUtilTest { } @Test - public void testReadCommentHeader() throws ParserException { - byte[] data = OggTestData.getCommentHeaderDataUTF8(); + public void testReadCommentHeader() throws IOException { + byte[] data = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "binary/vorbis/comment_header"); ParsableByteArray headerData = new ParsableByteArray(data, data.length); VorbisUtil.CommentHeader commentHeader = VorbisUtil.readVorbisCommentHeader(headerData); @@ -76,8 +83,10 @@ public final class VorbisUtilTest { } @Test - public void testReadVorbisModes() throws ParserException { - byte[] data = OggTestData.getSetupHeaderData(); + public void testReadVorbisModes() throws IOException { + byte[] data = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "binary/vorbis/setup_header"); ParsableByteArray headerData = new ParsableByteArray(data, data.length); VorbisUtil.Mode[] modes = VorbisUtil.readVorbisModes(headerData, 2); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java index 3aac12a1a3..c4fd9e21ec 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java @@ -30,10 +30,30 @@ public class FlacExtractorTest { } @Test - public void testSampleWithId3() throws Exception { + public void testSampleWithId3HeaderAndId3Enabled() throws Exception { ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_with_id3.flac"); } + @Test + public void testSampleWithId3HeaderAndId3Disabled() throws Exception { + // The same file is used for testing the extractor with and without ID3 enabled as the test does + // not check the metadata outputted. It only checks that the file is parsed correctly in both + // cases. + ExtractorAsserts.assertBehavior( + () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA), + "flac/bear_with_id3.flac"); + } + + @Test + public void testSampleWithVorbisComments() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_with_vorbis_comments.flac"); + } + + @Test + public void testSampleWithPicture() throws Exception { + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_with_picture.flac"); + } + @Test public void testOneMetadataBlock() throws Exception { ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_one_metadata_block.flac"); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggTestData.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggTestData.java index c963a8f658..1cd3d5e5d2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggTestData.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggTestData.java @@ -62,1012 +62,4 @@ import com.google.android.exoplayer2.testutil.TestUtil; pageSegmentCount); } - /** - * Returns the initial two pages of bytes which by spec contain the three vorbis header packets: - * identification, comment and setup header. - */ - public static byte[] getVorbisHeaderPages() { - byte[] data = new byte[VORBIS_HEADER_PAGES.length]; - System.arraycopy(VORBIS_HEADER_PAGES, 0, data, 0, VORBIS_HEADER_PAGES.length); - return data; - } - - /** Returns a valid vorbis identification header in bytes. */ - public static byte[] getIdentificationHeaderData() { - int idHeaderStart = 28; - int idHeaderLength = 30; - byte[] idHeaderData = new byte[idHeaderLength]; - System.arraycopy(VORBIS_HEADER_PAGES, idHeaderStart, idHeaderData, 0, idHeaderLength); - return idHeaderData; - } - - /** Returns a valid vorbis comment header with 3 comments including utf8 chars in bytes. */ - public static byte[] getCommentHeaderDataUTF8() { - byte[] commentHeaderData = new byte[COMMENT_HEADER_WITH_UTF8.length]; - System.arraycopy( - COMMENT_HEADER_WITH_UTF8, 0, commentHeaderData, 0, COMMENT_HEADER_WITH_UTF8.length); - return commentHeaderData; - } - - /** Returns a valid vorbis setup header in bytes. */ - public static byte[] getSetupHeaderData() { - int setupHeaderStart = 146; - int setupHeaderLength = VORBIS_HEADER_PAGES.length - setupHeaderStart; - byte[] setupHeaderData = new byte[setupHeaderLength]; - System.arraycopy(VORBIS_HEADER_PAGES, setupHeaderStart, setupHeaderData, 0, setupHeaderLength); - return setupHeaderData; - } - - private static final byte[] COMMENT_HEADER_WITH_UTF8 = { - (byte) 0x03, (byte) 0x76, (byte) 0x6f, (byte) 0x72, // 3, v, o, r, - (byte) 0x62, (byte) 0x69, (byte) 0x73, (byte) 0x2b, // b, i, s, . - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x58, - (byte) 0x69, (byte) 0x70, (byte) 0x68, (byte) 0x2e, - (byte) 0x4f, (byte) 0x72, (byte) 0x67, (byte) 0x20, - (byte) 0x6c, (byte) 0x69, (byte) 0x62, (byte) 0x56, - (byte) 0x6f, (byte) 0x72, (byte) 0x62, (byte) 0x69, - (byte) 0x73, (byte) 0x20, (byte) 0x49, (byte) 0x20, - (byte) 0x32, (byte) 0x30, (byte) 0x31, (byte) 0x32, - (byte) 0x30, (byte) 0x32, (byte) 0x30, (byte) 0x33, - (byte) 0x20, (byte) 0x28, (byte) 0x4f, (byte) 0x6d, - (byte) 0x6e, (byte) 0x69, (byte) 0x70, (byte) 0x72, - (byte) 0x65, (byte) 0x73, (byte) 0x65, (byte) 0x6e, - (byte) 0x74, (byte) 0x29, (byte) 0x03, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x0a, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x41, (byte) 0x4c, - (byte) 0x42, (byte) 0x55, (byte) 0x4d, (byte) 0x3d, - (byte) 0xc3, (byte) 0xa4, (byte) 0xc3, (byte) 0xb6, - (byte) 0x13, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x54, (byte) 0x49, (byte) 0x54, (byte) 0x4c, - (byte) 0x45, (byte) 0x3d, (byte) 0x41, (byte) 0x20, - (byte) 0x73, (byte) 0x61, (byte) 0x6d, (byte) 0x70, - (byte) 0x6c, (byte) 0x65, (byte) 0x20, (byte) 0x73, - (byte) 0x6f, (byte) 0x6e, (byte) 0x67, (byte) 0x0d, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x41, - (byte) 0x52, (byte) 0x54, (byte) 0x49, (byte) 0x53, - (byte) 0x54, (byte) 0x3d, (byte) 0x47, (byte) 0x6f, - (byte) 0x6f, (byte) 0x67, (byte) 0x6c, (byte) 0x65, - (byte) 0x01 - }; - - // two OGG pages with 3 packets (id, comment and setup header) - // length: 3743 bytes - private static final byte[] VORBIS_HEADER_PAGES = { /* capture pattern ogg header 1 */ - (byte) 0x4f, (byte) 0x67, (byte) 0x67, (byte) 0x53, // O,g,g,S : start pos 0 - (byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x5e, (byte) 0x5f, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x83, (byte) 0x36, - (byte) 0xe3, (byte) 0x49, (byte) 0x01, (byte) 0x1e, /* capture pattern vorbis id header */ - (byte) 0x01, (byte) 0x76, (byte) 0x6f, (byte) 0x72, // 1,v,o,r : start pos 28 - (byte) 0x62, (byte) 0x69, (byte) 0x73, (byte) 0x00, // b,i,s,. - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x02, - (byte) 0x22, (byte) 0x56, (byte) 0x00, (byte) 0x00, - (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, - (byte) 0x6a, (byte) 0x04, (byte) 0x01, (byte) 0x00, - (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, /* capture pattern ogg header 2 */ - (byte) 0xa9, (byte) 0x01, (byte) 0x4f, (byte) 0x67, // .,.,O,g : start pos 86 - (byte) 0x67, (byte) 0x53, (byte) 0x00, (byte) 0x00, // g,S,.,. - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x5e, (byte) 0x5f, (byte) 0x00, (byte) 0x00, - (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x69, (byte) 0xf8, (byte) 0xeb, (byte) 0xe1, - (byte) 0x10, (byte) 0x2d, (byte) 0xff, (byte) 0xff, - (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, - (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, - (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, /* capture pattern vorbis comment header*/ - (byte) 0x1b, (byte) 0x03, (byte) 0x76, (byte) 0x6f, // .,3,v,o : start pos 101 - (byte) 0x72, (byte) 0x62, (byte) 0x69, (byte) 0x73, // r,b,i,s - (byte) 0x1d, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x58, (byte) 0x69, (byte) 0x70, (byte) 0x68, - (byte) 0x2e, (byte) 0x4f, (byte) 0x72, (byte) 0x67, - (byte) 0x20, (byte) 0x6c, (byte) 0x69, (byte) 0x62, - (byte) 0x56, (byte) 0x6f, (byte) 0x72, (byte) 0x62, - (byte) 0x69, (byte) 0x73, (byte) 0x20, (byte) 0x49, - (byte) 0x20, (byte) 0x32, (byte) 0x30, (byte) 0x30, - (byte) 0x33, (byte) 0x30, (byte) 0x39, (byte) 0x30, - (byte) 0x39, (byte) 0x00, (byte) 0x00, (byte) 0x00, /* capture pattern vorbis setup header */ - (byte) 0x00, (byte) 0x01, (byte) 0x05, (byte) 0x76, // .,.,5,v : start pos 146 - (byte) 0x6f, (byte) 0x72, (byte) 0x62, (byte) 0x69, // o,r,b,i - (byte) 0x73, (byte) 0x22, (byte) 0x42, (byte) 0x43, // s,. - (byte) 0x56, (byte) 0x01, (byte) 0x00, (byte) 0x40, - (byte) 0x00, (byte) 0x00, (byte) 0x18, (byte) 0x42, - (byte) 0x10, (byte) 0x2a, (byte) 0x05, (byte) 0xad, - (byte) 0x63, (byte) 0x8e, (byte) 0x3a, (byte) 0xc8, - (byte) 0x15, (byte) 0x21, (byte) 0x8c, (byte) 0x19, - (byte) 0xa2, (byte) 0xa0, (byte) 0x42, (byte) 0xca, - (byte) 0x29, (byte) 0xc7, (byte) 0x1d, (byte) 0x42, - (byte) 0xd0, (byte) 0x21, (byte) 0xa3, (byte) 0x24, - (byte) 0x43, (byte) 0x88, (byte) 0x3a, (byte) 0xc6, - (byte) 0x35, (byte) 0xc7, (byte) 0x18, (byte) 0x63, - (byte) 0x47, (byte) 0xb9, (byte) 0x64, (byte) 0x8a, - (byte) 0x42, (byte) 0xc9, (byte) 0x81, (byte) 0xd0, - (byte) 0x90, (byte) 0x55, (byte) 0x00, (byte) 0x00, - (byte) 0x40, (byte) 0x00, (byte) 0x00, (byte) 0xa4, - (byte) 0x1c, (byte) 0x57, (byte) 0x50, (byte) 0x72, - (byte) 0x49, (byte) 0x2d, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0xa3, (byte) 0x18, (byte) 0x57, - (byte) 0xcc, (byte) 0x71, (byte) 0xe8, (byte) 0x20, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xe5, - (byte) 0x20, (byte) 0x67, (byte) 0xcc, (byte) 0x71, - (byte) 0x09, (byte) 0x25, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0x8e, (byte) 0x39, (byte) 0xe7, - (byte) 0x92, (byte) 0x72, (byte) 0x8e, (byte) 0x31, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xa3, - (byte) 0x18, (byte) 0x57, (byte) 0x0e, (byte) 0x72, - (byte) 0x29, (byte) 0x2d, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0x81, (byte) 0x14, (byte) 0x47, - (byte) 0x8a, (byte) 0x71, (byte) 0xa7, (byte) 0x18, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xa4, - (byte) 0x1c, (byte) 0x47, (byte) 0x8a, (byte) 0x71, - (byte) 0xa8, (byte) 0x18, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0x6d, (byte) 0x31, (byte) 0xb7, - (byte) 0x92, (byte) 0x72, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xe6, - (byte) 0x20, (byte) 0x87, (byte) 0x52, (byte) 0x72, - (byte) 0xae, (byte) 0x35, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0xa4, (byte) 0x18, (byte) 0x67, - (byte) 0x0e, (byte) 0x72, (byte) 0x0b, (byte) 0x25, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xc6, - (byte) 0x20, (byte) 0x67, (byte) 0xcc, (byte) 0x71, - (byte) 0xeb, (byte) 0x20, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0x8c, (byte) 0x35, (byte) 0xb7, - (byte) 0xd4, (byte) 0x72, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, - (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, - (byte) 0xce, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0x8c, (byte) 0x31, (byte) 0xe7, - (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0x6e, - (byte) 0x31, (byte) 0xe7, (byte) 0x16, (byte) 0x73, - (byte) 0xae, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0xce, (byte) 0x39, (byte) 0xe7, - (byte) 0x1c, (byte) 0x73, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0x20, - (byte) 0x34, (byte) 0x64, (byte) 0x15, (byte) 0x00, - (byte) 0x90, (byte) 0x00, (byte) 0x00, (byte) 0xa0, - (byte) 0xa1, (byte) 0x28, (byte) 0x8a, (byte) 0xe2, - (byte) 0x28, (byte) 0x0e, (byte) 0x10, (byte) 0x1a, - (byte) 0xb2, (byte) 0x0a, (byte) 0x00, (byte) 0xc8, - (byte) 0x00, (byte) 0x00, (byte) 0x10, (byte) 0x40, - (byte) 0x71, (byte) 0x14, (byte) 0x47, (byte) 0x91, - (byte) 0x14, (byte) 0x4b, (byte) 0xb1, (byte) 0x1c, - (byte) 0xcb, (byte) 0xd1, (byte) 0x24, (byte) 0x0d, - (byte) 0x08, (byte) 0x0d, (byte) 0x59, (byte) 0x05, - (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00, - (byte) 0x08, (byte) 0x00, (byte) 0x00, (byte) 0xa0, - (byte) 0x48, (byte) 0x86, (byte) 0xa4, (byte) 0x48, - (byte) 0x8a, (byte) 0xa5, (byte) 0x58, (byte) 0x8e, - (byte) 0x66, (byte) 0x69, (byte) 0x9e, (byte) 0x26, - (byte) 0x7a, (byte) 0xa2, (byte) 0x28, (byte) 0x9a, - (byte) 0xa2, (byte) 0x2a, (byte) 0xab, (byte) 0xb2, - (byte) 0x69, (byte) 0xca, (byte) 0xb2, (byte) 0x2c, - (byte) 0xcb, (byte) 0xb2, (byte) 0xeb, (byte) 0xba, - (byte) 0x2e, (byte) 0x10, (byte) 0x1a, (byte) 0xb2, - (byte) 0x0a, (byte) 0x00, (byte) 0x48, (byte) 0x00, - (byte) 0x00, (byte) 0x50, (byte) 0x51, (byte) 0x14, - (byte) 0xc5, (byte) 0x70, (byte) 0x14, (byte) 0x07, - (byte) 0x08, (byte) 0x0d, (byte) 0x59, (byte) 0x05, - (byte) 0x00, (byte) 0x64, (byte) 0x00, (byte) 0x00, - (byte) 0x08, (byte) 0x60, (byte) 0x28, (byte) 0x8a, - (byte) 0xa3, (byte) 0x38, (byte) 0x8e, (byte) 0xe4, - (byte) 0x58, (byte) 0x92, (byte) 0xa5, (byte) 0x59, - (byte) 0x9e, (byte) 0x07, (byte) 0x84, (byte) 0x86, - (byte) 0xac, (byte) 0x02, (byte) 0x00, (byte) 0x80, - (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x00, - (byte) 0x00, (byte) 0x50, (byte) 0x0c, (byte) 0x47, - (byte) 0xb1, (byte) 0x14, (byte) 0x4d, (byte) 0xf1, - (byte) 0x24, (byte) 0xcf, (byte) 0xf2, (byte) 0x3c, - (byte) 0xcf, (byte) 0xf3, (byte) 0x3c, (byte) 0xcf, - (byte) 0xf3, (byte) 0x3c, (byte) 0xcf, (byte) 0xf3, - (byte) 0x3c, (byte) 0xcf, (byte) 0xf3, (byte) 0x3c, - (byte) 0xcf, (byte) 0xf3, (byte) 0x3c, (byte) 0xcf, - (byte) 0xf3, (byte) 0x3c, (byte) 0x0d, (byte) 0x08, - (byte) 0x0d, (byte) 0x59, (byte) 0x05, (byte) 0x00, - (byte) 0x20, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x82, (byte) 0x28, (byte) 0x64, (byte) 0x18, - (byte) 0x03, (byte) 0x42, (byte) 0x43, (byte) 0x56, - (byte) 0x01, (byte) 0x00, (byte) 0x40, (byte) 0x00, - (byte) 0x00, (byte) 0x08, (byte) 0x21, (byte) 0x1a, - (byte) 0x19, (byte) 0x43, (byte) 0x9d, (byte) 0x52, - (byte) 0x12, (byte) 0x5c, (byte) 0x0a, (byte) 0x16, - (byte) 0x42, (byte) 0x1c, (byte) 0x11, (byte) 0x43, - (byte) 0x1d, (byte) 0x42, (byte) 0xce, (byte) 0x43, - (byte) 0xa9, (byte) 0xa5, (byte) 0x83, (byte) 0xe0, - (byte) 0x29, (byte) 0x85, (byte) 0x25, (byte) 0x63, - (byte) 0xd2, (byte) 0x53, (byte) 0xac, (byte) 0x41, - (byte) 0x08, (byte) 0x21, (byte) 0x7c, (byte) 0xef, - (byte) 0x3d, (byte) 0xf7, (byte) 0xde, (byte) 0x7b, - (byte) 0xef, (byte) 0x81, (byte) 0xd0, (byte) 0x90, - (byte) 0x55, (byte) 0x00, (byte) 0x00, (byte) 0x10, - (byte) 0x00, (byte) 0x00, (byte) 0x61, (byte) 0x14, - (byte) 0x38, (byte) 0x88, (byte) 0x81, (byte) 0xc7, - (byte) 0x24, (byte) 0x08, (byte) 0x21, (byte) 0x84, - (byte) 0x62, (byte) 0x14, (byte) 0x27, (byte) 0x44, - (byte) 0x71, (byte) 0xa6, (byte) 0x20, (byte) 0x08, - (byte) 0x21, (byte) 0x84, (byte) 0xe5, (byte) 0x24, - (byte) 0x58, (byte) 0xca, (byte) 0x79, (byte) 0xe8, - (byte) 0x24, (byte) 0x08, (byte) 0xdd, (byte) 0x83, - (byte) 0x10, (byte) 0x42, (byte) 0xb8, (byte) 0x9c, - (byte) 0x7b, (byte) 0xcb, (byte) 0xb9, (byte) 0xf7, - (byte) 0xde, (byte) 0x7b, (byte) 0x20, (byte) 0x34, - (byte) 0x64, (byte) 0x15, (byte) 0x00, (byte) 0x00, - (byte) 0x08, (byte) 0x00, (byte) 0xc0, (byte) 0x20, - (byte) 0x84, (byte) 0x10, (byte) 0x42, (byte) 0x08, - (byte) 0x21, (byte) 0x84, (byte) 0x10, (byte) 0x42, - (byte) 0x08, (byte) 0x29, (byte) 0xa4, (byte) 0x94, - (byte) 0x52, (byte) 0x48, (byte) 0x29, (byte) 0xa6, - (byte) 0x98, (byte) 0x62, (byte) 0x8a, (byte) 0x29, - (byte) 0xc7, (byte) 0x1c, (byte) 0x73, (byte) 0xcc, - (byte) 0x31, (byte) 0xc7, (byte) 0x20, (byte) 0x83, - (byte) 0x0c, (byte) 0x32, (byte) 0xe8, (byte) 0xa0, - (byte) 0x93, (byte) 0x4e, (byte) 0x3a, (byte) 0xc9, - (byte) 0xa4, (byte) 0x92, (byte) 0x4e, (byte) 0x3a, - (byte) 0xca, (byte) 0x24, (byte) 0xa3, (byte) 0x8e, - (byte) 0x52, (byte) 0x6b, (byte) 0x29, (byte) 0xb5, - (byte) 0x14, (byte) 0x53, (byte) 0x4c, (byte) 0xb1, - (byte) 0xe5, (byte) 0x16, (byte) 0x63, (byte) 0xad, - (byte) 0xb5, (byte) 0xd6, (byte) 0x9c, (byte) 0x73, - (byte) 0xaf, (byte) 0x41, (byte) 0x29, (byte) 0x63, - (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, - (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, - (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, - (byte) 0xc6, (byte) 0x18, (byte) 0x23, (byte) 0x08, - (byte) 0x0d, (byte) 0x59, (byte) 0x05, (byte) 0x00, - (byte) 0x80, (byte) 0x00, (byte) 0x00, (byte) 0x10, - (byte) 0x06, (byte) 0x19, (byte) 0x64, (byte) 0x90, - (byte) 0x41, (byte) 0x08, (byte) 0x21, (byte) 0x84, - (byte) 0x14, (byte) 0x52, (byte) 0x48, (byte) 0x29, - (byte) 0xa6, (byte) 0x98, (byte) 0x72, (byte) 0xcc, - (byte) 0x31, (byte) 0xc7, (byte) 0x1c, (byte) 0x03, - (byte) 0x42, (byte) 0x43, (byte) 0x56, (byte) 0x01, - (byte) 0x00, (byte) 0x80, (byte) 0x00, (byte) 0x00, - (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x1c, (byte) 0x45, (byte) 0x52, (byte) 0x24, - (byte) 0x47, (byte) 0x72, (byte) 0x24, (byte) 0x47, - (byte) 0x92, (byte) 0x24, (byte) 0xc9, (byte) 0x92, - (byte) 0x2c, (byte) 0x49, (byte) 0x93, (byte) 0x3c, - (byte) 0xcb, (byte) 0xb3, (byte) 0x3c, (byte) 0xcb, - (byte) 0xb3, (byte) 0x3c, (byte) 0x4d, (byte) 0xd4, - (byte) 0x44, (byte) 0x4d, (byte) 0x15, (byte) 0x55, - (byte) 0xd5, (byte) 0x55, (byte) 0x6d, (byte) 0xd7, - (byte) 0xf6, (byte) 0x6d, (byte) 0x5f, (byte) 0xf6, - (byte) 0x6d, (byte) 0xdf, (byte) 0xd5, (byte) 0x65, - (byte) 0xdf, (byte) 0xf6, (byte) 0x65, (byte) 0xdb, - (byte) 0xd5, (byte) 0x65, (byte) 0x5d, (byte) 0x96, - (byte) 0x65, (byte) 0xdd, (byte) 0xb5, (byte) 0x6d, - (byte) 0x5d, (byte) 0xd6, (byte) 0x5d, (byte) 0x5d, - (byte) 0xd7, (byte) 0x75, (byte) 0x5d, (byte) 0xd7, - (byte) 0x75, (byte) 0x5d, (byte) 0xd7, (byte) 0x75, - (byte) 0x5d, (byte) 0xd7, (byte) 0x75, (byte) 0x5d, - (byte) 0xd7, (byte) 0x75, (byte) 0x5d, (byte) 0xd7, - (byte) 0x81, (byte) 0xd0, (byte) 0x90, (byte) 0x55, - (byte) 0x00, (byte) 0x80, (byte) 0x04, (byte) 0x00, - (byte) 0x80, (byte) 0x8e, (byte) 0xe4, (byte) 0x38, - (byte) 0x8e, (byte) 0xe4, (byte) 0x38, (byte) 0x8e, - (byte) 0xe4, (byte) 0x48, (byte) 0x8e, (byte) 0xa4, - (byte) 0x48, (byte) 0x0a, (byte) 0x10, (byte) 0x1a, - (byte) 0xb2, (byte) 0x0a, (byte) 0x00, (byte) 0x90, - (byte) 0x01, (byte) 0x00, (byte) 0x10, (byte) 0x00, - (byte) 0x80, (byte) 0xa3, (byte) 0x38, (byte) 0x8a, - (byte) 0xe3, (byte) 0x48, (byte) 0x8e, (byte) 0xe4, - (byte) 0x58, (byte) 0x8e, (byte) 0x25, (byte) 0x59, - (byte) 0x92, (byte) 0x26, (byte) 0x69, (byte) 0x96, - (byte) 0x67, (byte) 0x79, (byte) 0x96, (byte) 0xa7, - (byte) 0x79, (byte) 0x9a, (byte) 0xa8, (byte) 0x89, - (byte) 0x1e, (byte) 0x10, (byte) 0x1a, (byte) 0xb2, - (byte) 0x0a, (byte) 0x00, (byte) 0x00, (byte) 0x04, - (byte) 0x00, (byte) 0x10, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x80, - (byte) 0xa2, (byte) 0x28, (byte) 0x8a, (byte) 0xa3, - (byte) 0x38, (byte) 0x8e, (byte) 0x24, (byte) 0x59, - (byte) 0x96, (byte) 0xa6, (byte) 0x69, (byte) 0x9e, - (byte) 0xa7, (byte) 0x7a, (byte) 0xa2, (byte) 0x28, - (byte) 0x9a, (byte) 0xaa, (byte) 0xaa, (byte) 0x8a, - (byte) 0xa6, (byte) 0xa9, (byte) 0xaa, (byte) 0xaa, - (byte) 0x6a, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, - (byte) 0x9a, (byte) 0xa6, (byte) 0x69, (byte) 0x9a, - (byte) 0xa6, (byte) 0x69, (byte) 0x9a, (byte) 0xa6, - (byte) 0x69, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, - (byte) 0x9a, (byte) 0xa6, (byte) 0x69, (byte) 0x9a, - (byte) 0xa6, (byte) 0x69, (byte) 0x9a, (byte) 0xa6, - (byte) 0x69, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, - (byte) 0x9a, (byte) 0xa6, (byte) 0x69, (byte) 0x9a, - (byte) 0xa6, (byte) 0x69, (byte) 0x02, (byte) 0xa1, - (byte) 0x21, (byte) 0xab, (byte) 0x00, (byte) 0x00, - (byte) 0x09, (byte) 0x00, (byte) 0x00, (byte) 0x1d, - (byte) 0xc7, (byte) 0x71, (byte) 0x1c, (byte) 0x47, - (byte) 0x71, (byte) 0x1c, (byte) 0xc7, (byte) 0x71, - (byte) 0x24, (byte) 0x47, (byte) 0x92, (byte) 0x24, - (byte) 0x20, (byte) 0x34, (byte) 0x64, (byte) 0x15, - (byte) 0x00, (byte) 0x20, (byte) 0x03, (byte) 0x00, - (byte) 0x20, (byte) 0x00, (byte) 0x00, (byte) 0x43, - (byte) 0x51, (byte) 0x1c, (byte) 0x45, (byte) 0x72, - (byte) 0x2c, (byte) 0xc7, (byte) 0x92, (byte) 0x34, - (byte) 0x4b, (byte) 0xb3, (byte) 0x3c, (byte) 0xcb, - (byte) 0xd3, (byte) 0x44, (byte) 0xcf, (byte) 0xf4, - (byte) 0x5c, (byte) 0x51, (byte) 0x36, (byte) 0x75, - (byte) 0x53, (byte) 0x57, (byte) 0x6d, (byte) 0x20, - (byte) 0x34, (byte) 0x64, (byte) 0x15, (byte) 0x00, - (byte) 0x00, (byte) 0x08, (byte) 0x00, (byte) 0x20, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0xc7, (byte) 0x73, - (byte) 0x3c, (byte) 0xc7, (byte) 0x73, (byte) 0x3c, - (byte) 0xc9, (byte) 0x93, (byte) 0x3c, (byte) 0xcb, - (byte) 0x73, (byte) 0x3c, (byte) 0xc7, (byte) 0x93, - (byte) 0x3c, (byte) 0x49, (byte) 0xd3, (byte) 0x34, - (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, - (byte) 0xd3, (byte) 0x34, (byte) 0x4d, (byte) 0xd3, - (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, - (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, - (byte) 0xd3, (byte) 0x34, (byte) 0x4d, (byte) 0xd3, - (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, - (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, - (byte) 0xd3, (byte) 0x34, (byte) 0x4d, (byte) 0xd3, - (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, - (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, - (byte) 0x03, (byte) 0x42, (byte) 0x43, (byte) 0x56, - (byte) 0x02, (byte) 0x00, (byte) 0x64, (byte) 0x00, - (byte) 0x00, (byte) 0x90, (byte) 0x02, (byte) 0xcf, - (byte) 0x42, (byte) 0x29, (byte) 0x2d, (byte) 0x46, - (byte) 0x02, (byte) 0x1c, (byte) 0x88, (byte) 0x98, - (byte) 0xa3, (byte) 0xd8, (byte) 0x7b, (byte) 0xef, - (byte) 0xbd, (byte) 0xf7, (byte) 0xde, (byte) 0x7b, - (byte) 0x65, (byte) 0x3c, (byte) 0x92, (byte) 0x88, - (byte) 0x49, (byte) 0xed, (byte) 0x31, (byte) 0xf4, - (byte) 0xd4, (byte) 0x31, (byte) 0x07, (byte) 0xb1, - (byte) 0x67, (byte) 0xc6, (byte) 0x23, (byte) 0x66, - (byte) 0x94, (byte) 0xa3, (byte) 0xd8, (byte) 0x29, - (byte) 0xcf, (byte) 0x1c, (byte) 0x42, (byte) 0x0c, - (byte) 0x62, (byte) 0xe8, (byte) 0x3c, (byte) 0x74, - (byte) 0x4a, (byte) 0x31, (byte) 0x88, (byte) 0x29, - (byte) 0xf5, (byte) 0x52, (byte) 0x32, (byte) 0xc6, - (byte) 0x20, (byte) 0xc6, (byte) 0xd8, (byte) 0x63, - (byte) 0x0c, (byte) 0x21, (byte) 0x94, (byte) 0x18, - (byte) 0x08, (byte) 0x0d, (byte) 0x59, (byte) 0x21, - (byte) 0x00, (byte) 0x84, (byte) 0x66, (byte) 0x00, - (byte) 0x18, (byte) 0x24, (byte) 0x09, (byte) 0x90, - (byte) 0x34, (byte) 0x0d, (byte) 0x90, (byte) 0x34, - (byte) 0x0d, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x24, (byte) 0x4f, (byte) 0x03, (byte) 0x34, - (byte) 0x51, (byte) 0x04, (byte) 0x34, (byte) 0x4f, - (byte) 0x04, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x49, (byte) 0xf3, (byte) 0x00, (byte) 0x4d, - (byte) 0xf4, (byte) 0x00, (byte) 0x4d, (byte) 0x14, - (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x90, (byte) 0x3c, (byte) 0x0d, (byte) 0xf0, - (byte) 0x44, (byte) 0x11, (byte) 0xd0, (byte) 0x44, - (byte) 0x11, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x34, (byte) 0x51, (byte) 0x04, (byte) 0x44, - (byte) 0x51, (byte) 0x05, (byte) 0x44, (byte) 0xd5, - (byte) 0x04, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x4d, (byte) 0x14, (byte) 0x01, (byte) 0x4f, - (byte) 0x15, (byte) 0x01, (byte) 0xd1, (byte) 0x54, - (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x90, (byte) 0x34, (byte) 0x0f, (byte) 0xd0, - (byte) 0x44, (byte) 0x11, (byte) 0xf0, (byte) 0x44, - (byte) 0x11, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x34, (byte) 0x51, (byte) 0x04, (byte) 0x44, - (byte) 0xd5, (byte) 0x04, (byte) 0x3c, (byte) 0x51, - (byte) 0x05, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x4d, (byte) 0x14, (byte) 0x01, (byte) 0xd1, - (byte) 0x54, (byte) 0x01, (byte) 0x51, (byte) 0x15, - (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x04, - (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x38, - (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x58, - (byte) 0x08, (byte) 0x85, (byte) 0x86, (byte) 0xac, - (byte) 0x08, (byte) 0x00, (byte) 0xe2, (byte) 0x04, - (byte) 0x00, (byte) 0x04, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10, - (byte) 0x00, (byte) 0x00, (byte) 0x30, (byte) 0xe0, - (byte) 0x00, (byte) 0x00, (byte) 0x10, (byte) 0x60, - (byte) 0x42, (byte) 0x19, (byte) 0x28, (byte) 0x34, - (byte) 0x64, (byte) 0x45, (byte) 0x00, (byte) 0x10, - (byte) 0x27, (byte) 0x00, (byte) 0x60, (byte) 0x70, - (byte) 0x1c, (byte) 0xcb, (byte) 0x02, (byte) 0x00, - (byte) 0x00, (byte) 0x47, (byte) 0x92, (byte) 0x34, - (byte) 0x0d, (byte) 0x00, (byte) 0x00, (byte) 0x1c, - (byte) 0x49, (byte) 0xd2, (byte) 0x34, (byte) 0x00, - (byte) 0x00, (byte) 0xd0, (byte) 0x34, (byte) 0x4d, - (byte) 0x14, (byte) 0x01, (byte) 0x00, (byte) 0xc0, - (byte) 0xd2, (byte) 0x34, (byte) 0x51, (byte) 0x04, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x10, (byte) 0x00, (byte) 0x00, (byte) 0x30, - (byte) 0xe0, (byte) 0x00, (byte) 0x00, (byte) 0x10, - (byte) 0x60, (byte) 0x42, (byte) 0x19, (byte) 0x28, - (byte) 0x34, (byte) 0x64, (byte) 0x25, (byte) 0x00, - (byte) 0x10, (byte) 0x05, (byte) 0x00, (byte) 0x60, - (byte) 0x30, (byte) 0x14, (byte) 0x4d, (byte) 0x03, - (byte) 0x58, (byte) 0x16, (byte) 0xc0, (byte) 0xb2, - (byte) 0x00, (byte) 0x9a, (byte) 0x06, (byte) 0xd0, - (byte) 0x34, (byte) 0x80, (byte) 0xe7, (byte) 0x01, - (byte) 0x3c, (byte) 0x11, (byte) 0x60, (byte) 0x9a, - (byte) 0x00, (byte) 0x40, (byte) 0x00, (byte) 0x00, - (byte) 0x40, (byte) 0x81, (byte) 0x03, (byte) 0x00, - (byte) 0x40, (byte) 0x80, (byte) 0x0d, (byte) 0x9a, - (byte) 0x12, (byte) 0x8b, (byte) 0x03, (byte) 0x14, - (byte) 0x1a, (byte) 0xb2, (byte) 0x12, (byte) 0x00, - (byte) 0x88, (byte) 0x02, (byte) 0x00, (byte) 0x30, - (byte) 0x28, (byte) 0x8a, (byte) 0x24, (byte) 0x59, - (byte) 0x96, (byte) 0xe7, (byte) 0x41, (byte) 0xd3, - (byte) 0x34, (byte) 0x4d, (byte) 0x14, (byte) 0xa1, - (byte) 0x69, (byte) 0x9a, (byte) 0x26, (byte) 0x8a, - (byte) 0xf0, (byte) 0x3c, (byte) 0xcf, (byte) 0x13, - (byte) 0x45, (byte) 0x78, (byte) 0x9e, (byte) 0xe7, - (byte) 0x99, (byte) 0x26, (byte) 0x44, (byte) 0xd1, - (byte) 0xf3, (byte) 0x4c, (byte) 0x13, (byte) 0xa2, - (byte) 0xe8, (byte) 0x79, (byte) 0xa6, (byte) 0x09, - (byte) 0xd3, (byte) 0x14, (byte) 0x45, (byte) 0xd3, - (byte) 0x04, (byte) 0xa2, (byte) 0x68, (byte) 0x9a, - (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x0a, - (byte) 0x1c, (byte) 0x00, (byte) 0x00, (byte) 0x02, - (byte) 0x6c, (byte) 0xd0, (byte) 0x94, (byte) 0x58, - (byte) 0x1c, (byte) 0xa0, (byte) 0xd0, (byte) 0x90, - (byte) 0x95, (byte) 0x00, (byte) 0x40, (byte) 0x48, - (byte) 0x00, (byte) 0x80, (byte) 0x41, (byte) 0x51, - (byte) 0x2c, (byte) 0xcb, (byte) 0xf3, (byte) 0x44, - (byte) 0x51, (byte) 0x14, (byte) 0x4d, (byte) 0x53, - (byte) 0x55, (byte) 0x5d, (byte) 0x17, (byte) 0x9a, - (byte) 0xe6, (byte) 0x79, (byte) 0xa2, (byte) 0x28, - (byte) 0x8a, (byte) 0xa6, (byte) 0xa9, (byte) 0xaa, - (byte) 0xae, (byte) 0x0b, (byte) 0x4d, (byte) 0xf3, - (byte) 0x3c, (byte) 0x51, (byte) 0x14, (byte) 0x45, - (byte) 0xd3, (byte) 0x54, (byte) 0x55, (byte) 0xd7, - (byte) 0x85, (byte) 0xe7, (byte) 0x79, (byte) 0xa2, - (byte) 0x29, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, - (byte) 0xaa, (byte) 0xaa, (byte) 0xeb, (byte) 0xc2, - (byte) 0xf3, (byte) 0x44, (byte) 0xd1, (byte) 0x34, - (byte) 0x4d, (byte) 0x53, (byte) 0x55, (byte) 0x55, - (byte) 0xd7, (byte) 0x75, (byte) 0xe1, (byte) 0x79, - (byte) 0xa2, (byte) 0x68, (byte) 0x9a, (byte) 0xa6, - (byte) 0xa9, (byte) 0xaa, (byte) 0xae, (byte) 0xeb, - (byte) 0xba, (byte) 0xf0, (byte) 0x3c, (byte) 0x51, - (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x54, - (byte) 0x55, (byte) 0xd7, (byte) 0x95, (byte) 0x65, - (byte) 0x88, (byte) 0xa2, (byte) 0x28, (byte) 0x9a, - (byte) 0xa6, (byte) 0x69, (byte) 0xaa, (byte) 0xaa, - (byte) 0xeb, (byte) 0xca, (byte) 0x32, (byte) 0x10, - (byte) 0x45, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, - (byte) 0x55, (byte) 0x75, (byte) 0x5d, (byte) 0x59, - (byte) 0x06, (byte) 0xa2, (byte) 0x68, (byte) 0x9a, - (byte) 0xaa, (byte) 0xea, (byte) 0xba, (byte) 0xae, - (byte) 0x2b, (byte) 0xcb, (byte) 0x40, (byte) 0x14, - (byte) 0x4d, (byte) 0x53, (byte) 0x55, (byte) 0x5d, - (byte) 0xd7, (byte) 0x75, (byte) 0x65, (byte) 0x19, - (byte) 0x98, (byte) 0xa6, (byte) 0x6a, (byte) 0xaa, - (byte) 0xaa, (byte) 0xeb, (byte) 0xca, (byte) 0xb2, - (byte) 0x2c, (byte) 0x03, (byte) 0x4c, (byte) 0x53, - (byte) 0x55, (byte) 0x5d, (byte) 0x57, (byte) 0x96, - (byte) 0x65, (byte) 0x19, (byte) 0xa0, (byte) 0xaa, - (byte) 0xae, (byte) 0xeb, (byte) 0xba, (byte) 0xb2, - (byte) 0x6c, (byte) 0xdb, (byte) 0x00, (byte) 0x55, - (byte) 0x75, (byte) 0x5d, (byte) 0xd7, (byte) 0x95, - (byte) 0x65, (byte) 0xdb, (byte) 0x06, (byte) 0xb8, - (byte) 0xae, (byte) 0xeb, (byte) 0xca, (byte) 0xb2, - (byte) 0x2c, (byte) 0xdb, (byte) 0x36, (byte) 0x00, - (byte) 0xd7, (byte) 0x95, (byte) 0x65, (byte) 0x59, - (byte) 0xb6, (byte) 0x6d, (byte) 0x01, (byte) 0x00, - (byte) 0x00, (byte) 0x07, (byte) 0x0e, (byte) 0x00, - (byte) 0x00, (byte) 0x01, (byte) 0x46, (byte) 0xd0, - (byte) 0x49, (byte) 0x46, (byte) 0x95, (byte) 0x45, - (byte) 0xd8, (byte) 0x68, (byte) 0xc2, (byte) 0x85, - (byte) 0x07, (byte) 0xa0, (byte) 0xd0, (byte) 0x90, - (byte) 0x15, (byte) 0x01, (byte) 0x40, (byte) 0x14, - (byte) 0x00, (byte) 0x00, (byte) 0x60, (byte) 0x8c, - (byte) 0x52, (byte) 0x8a, (byte) 0x29, (byte) 0x65, - (byte) 0x18, (byte) 0x93, (byte) 0x50, (byte) 0x4a, - (byte) 0x09, (byte) 0x0d, (byte) 0x63, (byte) 0x52, - (byte) 0x4a, (byte) 0x2a, (byte) 0xa5, (byte) 0x92, - (byte) 0x92, (byte) 0x52, (byte) 0x4a, (byte) 0xa5, - (byte) 0x54, (byte) 0x12, (byte) 0x52, (byte) 0x4a, - (byte) 0xa9, (byte) 0x94, (byte) 0x4a, (byte) 0x4a, - (byte) 0x4a, (byte) 0x29, (byte) 0x95, (byte) 0x92, - (byte) 0x51, (byte) 0x4a, (byte) 0x29, (byte) 0xb5, - (byte) 0x96, (byte) 0x2a, (byte) 0x29, (byte) 0xa9, - (byte) 0x94, (byte) 0x94, (byte) 0x52, (byte) 0x25, - (byte) 0xa5, (byte) 0xa4, (byte) 0x92, (byte) 0x52, - (byte) 0x2a, (byte) 0x00, (byte) 0x00, (byte) 0xec, - (byte) 0xc0, (byte) 0x01, (byte) 0x00, (byte) 0xec, - (byte) 0xc0, (byte) 0x42, (byte) 0x28, (byte) 0x34, - (byte) 0x64, (byte) 0x25, (byte) 0x00, (byte) 0x90, - (byte) 0x07, (byte) 0x00, (byte) 0x40, (byte) 0x10, - (byte) 0x82, (byte) 0x14, (byte) 0x63, (byte) 0x8c, - (byte) 0x39, (byte) 0x27, (byte) 0xa5, (byte) 0x54, - (byte) 0x8a, (byte) 0x31, (byte) 0xe7, (byte) 0x9c, - (byte) 0x93, (byte) 0x52, (byte) 0x2a, (byte) 0xc5, - (byte) 0x98, (byte) 0x73, (byte) 0xce, (byte) 0x49, - (byte) 0x29, (byte) 0x19, (byte) 0x63, (byte) 0xcc, - (byte) 0x39, (byte) 0xe7, (byte) 0xa4, (byte) 0x94, - (byte) 0x8c, (byte) 0x31, (byte) 0xe6, (byte) 0x9c, - (byte) 0x73, (byte) 0x52, (byte) 0x4a, (byte) 0xc6, - (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, - (byte) 0x29, (byte) 0x25, (byte) 0x63, (byte) 0xce, - (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x94, - (byte) 0xd2, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, - (byte) 0x83, (byte) 0x50, (byte) 0x4a, (byte) 0x29, - (byte) 0xa5, (byte) 0x73, (byte) 0xce, (byte) 0x41, - (byte) 0x28, (byte) 0xa5, (byte) 0x94, (byte) 0x12, - (byte) 0x42, (byte) 0xe7, (byte) 0x20, (byte) 0x94, - (byte) 0x52, (byte) 0x4a, (byte) 0xe9, (byte) 0x9c, - (byte) 0x73, (byte) 0x10, (byte) 0x0a, (byte) 0x00, - (byte) 0x00, (byte) 0x2a, (byte) 0x70, (byte) 0x00, - (byte) 0x00, (byte) 0x08, (byte) 0xb0, (byte) 0x51, - (byte) 0x64, (byte) 0x73, (byte) 0x82, (byte) 0x91, - (byte) 0xa0, (byte) 0x42, (byte) 0x43, (byte) 0x56, - (byte) 0x02, (byte) 0x00, (byte) 0xa9, (byte) 0x00, - (byte) 0x00, (byte) 0x06, (byte) 0xc7, (byte) 0xb1, - (byte) 0x2c, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, - (byte) 0xcf, (byte) 0x13, (byte) 0x45, (byte) 0x4b, - (byte) 0x92, (byte) 0x34, (byte) 0xcf, (byte) 0x13, - (byte) 0x3d, (byte) 0x4f, (byte) 0x14, (byte) 0x4d, - (byte) 0xd5, (byte) 0x92, (byte) 0x24, (byte) 0xcf, - (byte) 0x13, (byte) 0x45, (byte) 0xcf, (byte) 0x13, - (byte) 0x4d, (byte) 0x53, (byte) 0xe5, (byte) 0x79, - (byte) 0x9e, (byte) 0x28, (byte) 0x8a, (byte) 0xa2, - (byte) 0x68, (byte) 0x9a, (byte) 0xaa, (byte) 0x4a, - (byte) 0x14, (byte) 0x45, (byte) 0x4f, (byte) 0x14, - (byte) 0x45, (byte) 0xd1, (byte) 0x34, (byte) 0x55, - (byte) 0x95, (byte) 0x2c, (byte) 0x8b, (byte) 0xa2, - (byte) 0x69, (byte) 0x9a, (byte) 0xa6, (byte) 0xaa, - (byte) 0xba, (byte) 0x2e, (byte) 0x5b, (byte) 0x16, - (byte) 0x45, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, - (byte) 0x55, (byte) 0x75, (byte) 0x5d, (byte) 0x98, - (byte) 0xa6, (byte) 0x28, (byte) 0xaa, (byte) 0xaa, - (byte) 0xeb, (byte) 0xca, (byte) 0x2e, (byte) 0x4c, - (byte) 0x53, (byte) 0x14, (byte) 0x4d, (byte) 0xd3, - (byte) 0x75, (byte) 0x65, (byte) 0x19, (byte) 0xb2, - (byte) 0xad, (byte) 0x9a, (byte) 0xaa, (byte) 0xea, - (byte) 0xba, (byte) 0xb2, (byte) 0x0d, (byte) 0xdb, - (byte) 0x36, (byte) 0x4d, (byte) 0x55, (byte) 0x75, - (byte) 0x5d, (byte) 0x59, (byte) 0x06, (byte) 0xae, - (byte) 0xeb, (byte) 0xba, (byte) 0xb2, (byte) 0x6c, - (byte) 0xeb, (byte) 0xc0, (byte) 0x75, (byte) 0x5d, - (byte) 0x57, (byte) 0x96, (byte) 0x6d, (byte) 0x5d, - (byte) 0x00, (byte) 0x00, (byte) 0x78, (byte) 0x82, - (byte) 0x03, (byte) 0x00, (byte) 0x50, (byte) 0x81, - (byte) 0x0d, (byte) 0xab, (byte) 0x23, (byte) 0x9c, - (byte) 0x14, (byte) 0x8d, (byte) 0x05, (byte) 0x16, - (byte) 0x1a, (byte) 0xb2, (byte) 0x12, (byte) 0x00, - (byte) 0xc8, (byte) 0x00, (byte) 0x00, (byte) 0x20, - (byte) 0x08, (byte) 0x41, (byte) 0x48, (byte) 0x29, - (byte) 0x85, (byte) 0x90, (byte) 0x52, (byte) 0x0a, - (byte) 0x21, (byte) 0xa5, (byte) 0x14, (byte) 0x42, - (byte) 0x4a, (byte) 0x29, (byte) 0x84, (byte) 0x04, - (byte) 0x00, (byte) 0x00, (byte) 0x0c, (byte) 0x38, - (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x98, - (byte) 0x50, (byte) 0x06, (byte) 0x0a, (byte) 0x0d, - (byte) 0x59, (byte) 0x09, (byte) 0x00, (byte) 0xa4, - (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x10, - (byte) 0x42, (byte) 0x08, (byte) 0x21, (byte) 0x84, - (byte) 0x10, (byte) 0x42, (byte) 0x08, (byte) 0x21, - (byte) 0x84, (byte) 0x10, (byte) 0x42, (byte) 0x08, - (byte) 0x21, (byte) 0x84, (byte) 0x10, (byte) 0x42, - (byte) 0x08, (byte) 0x21, (byte) 0x84, (byte) 0x10, - (byte) 0x42, (byte) 0x08, (byte) 0x21, (byte) 0x84, - (byte) 0x10, (byte) 0x42, (byte) 0x08, (byte) 0x21, - (byte) 0x84, (byte) 0x10, (byte) 0x42, (byte) 0x08, - (byte) 0x21, (byte) 0x84, (byte) 0x10, (byte) 0x42, - (byte) 0x08, (byte) 0x21, (byte) 0x84, (byte) 0x10, - (byte) 0x42, (byte) 0x08, (byte) 0x21, (byte) 0x84, - (byte) 0x10, (byte) 0x42, (byte) 0x08, (byte) 0x21, - (byte) 0x84, (byte) 0xce, (byte) 0x39, (byte) 0xe7, - (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, - (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, - (byte) 0xce, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0xce, (byte) 0x39, (byte) 0xe7, - (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, - (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, - (byte) 0xce, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, - (byte) 0x73, (byte) 0xce, (byte) 0x39, (byte) 0xe7, - (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, - (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, - (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, - (byte) 0x02, (byte) 0x00, (byte) 0xb1, (byte) 0x2b, - (byte) 0x1c, (byte) 0x00, (byte) 0x76, (byte) 0x22, - (byte) 0x6c, (byte) 0x58, (byte) 0x1d, (byte) 0xe1, - (byte) 0xa4, (byte) 0x68, (byte) 0x2c, (byte) 0xb0, - (byte) 0xd0, (byte) 0x90, (byte) 0x95, (byte) 0x00, - (byte) 0x40, (byte) 0x38, (byte) 0x00, (byte) 0x00, - (byte) 0x60, (byte) 0x8c, (byte) 0x31, (byte) 0xce, - (byte) 0x59, (byte) 0xac, (byte) 0xb5, (byte) 0xd6, - (byte) 0x5a, (byte) 0x2b, (byte) 0xa5, (byte) 0x94, - (byte) 0x92, (byte) 0x50, (byte) 0x6b, (byte) 0xad, - (byte) 0xb5, (byte) 0xd6, (byte) 0x9a, (byte) 0x29, - (byte) 0xa4, (byte) 0x94, (byte) 0x84, (byte) 0x16, - (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, - (byte) 0x98, (byte) 0x31, (byte) 0x08, (byte) 0x29, - (byte) 0xb5, (byte) 0x18, (byte) 0x63, (byte) 0x8c, - (byte) 0x31, (byte) 0xc6, (byte) 0x8c, (byte) 0x39, - (byte) 0x47, (byte) 0x2d, (byte) 0xc6, (byte) 0x18, - (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xb6, - (byte) 0x56, (byte) 0x4a, (byte) 0x6c, (byte) 0x31, - (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, - (byte) 0xb1, (byte) 0xb5, (byte) 0x52, (byte) 0x62, - (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, - (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, - (byte) 0x16, (byte) 0x5b, (byte) 0x8c, (byte) 0x31, - (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, - (byte) 0x31, (byte) 0xb6, (byte) 0x18, (byte) 0x63, - (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, - (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, - (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, - (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, - (byte) 0x31, (byte) 0xb6, (byte) 0x18, (byte) 0x63, - (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, - (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, - (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, - (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, - (byte) 0x31, (byte) 0xc6, (byte) 0x18, (byte) 0x63, - (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, - (byte) 0x63, (byte) 0x6c, (byte) 0x31, (byte) 0xc6, - (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, - (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, - (byte) 0x31, (byte) 0xc6, (byte) 0x18, (byte) 0x63, - (byte) 0x2c, (byte) 0x00, (byte) 0xc0, (byte) 0xe4, - (byte) 0xc1, (byte) 0x01, (byte) 0x00, (byte) 0x2a, - (byte) 0xc1, (byte) 0xc6, (byte) 0x19, (byte) 0x56, - (byte) 0x92, (byte) 0xce, (byte) 0x0a, (byte) 0x47, - (byte) 0x83, (byte) 0x0b, (byte) 0x0d, (byte) 0x59, - (byte) 0x09, (byte) 0x00, (byte) 0xe4, (byte) 0x06, - (byte) 0x00, (byte) 0x00, (byte) 0xc6, (byte) 0x28, - (byte) 0xc5, (byte) 0x98, (byte) 0x63, (byte) 0xce, - (byte) 0x41, (byte) 0x08, (byte) 0xa1, (byte) 0x94, - (byte) 0x12, (byte) 0x4a, (byte) 0x49, (byte) 0xad, - (byte) 0x75, (byte) 0xce, (byte) 0x39, (byte) 0x08, - (byte) 0x21, (byte) 0x94, (byte) 0x52, (byte) 0x4a, - (byte) 0x49, (byte) 0xa9, (byte) 0xb4, (byte) 0x94, - (byte) 0x62, (byte) 0xca, (byte) 0x98, (byte) 0x73, - (byte) 0xce, (byte) 0x41, (byte) 0x08, (byte) 0xa5, - (byte) 0x94, (byte) 0x12, (byte) 0x4a, (byte) 0x49, - (byte) 0xa9, (byte) 0xa5, (byte) 0xd4, (byte) 0x39, - (byte) 0xe7, (byte) 0x20, (byte) 0x94, (byte) 0x52, - (byte) 0x4a, (byte) 0x4a, (byte) 0x29, (byte) 0xa5, - (byte) 0x94, (byte) 0x5a, (byte) 0x6a, (byte) 0xad, - (byte) 0x73, (byte) 0x10, (byte) 0x42, (byte) 0x08, - (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x4a, - (byte) 0x4a, (byte) 0x29, (byte) 0xa5, (byte) 0xd4, - (byte) 0x52, (byte) 0x08, (byte) 0x21, (byte) 0x94, - (byte) 0x52, (byte) 0x4a, (byte) 0x2a, (byte) 0x29, - (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x6b, - (byte) 0xad, (byte) 0xa5, (byte) 0x10, (byte) 0x42, - (byte) 0x28, (byte) 0xa5, (byte) 0x94, (byte) 0x94, - (byte) 0x52, (byte) 0x4a, (byte) 0x29, (byte) 0xa5, - (byte) 0xd4, (byte) 0x5a, (byte) 0x8b, (byte) 0xa1, - (byte) 0x94, (byte) 0x90, (byte) 0x4a, (byte) 0x29, - (byte) 0x25, (byte) 0xa5, (byte) 0x94, (byte) 0x52, - (byte) 0x49, (byte) 0x2d, (byte) 0xb5, (byte) 0x96, - (byte) 0x5a, (byte) 0x2a, (byte) 0xa1, (byte) 0x94, - (byte) 0x54, (byte) 0x52, (byte) 0x4a, (byte) 0x29, - (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x6b, - (byte) 0xa9, (byte) 0xb5, (byte) 0x56, (byte) 0x4a, - (byte) 0x49, (byte) 0x25, (byte) 0xa5, (byte) 0x94, - (byte) 0x52, (byte) 0x4a, (byte) 0x29, (byte) 0xa5, - (byte) 0xd4, (byte) 0x62, (byte) 0x6b, (byte) 0x29, - (byte) 0x94, (byte) 0x92, (byte) 0x52, (byte) 0x49, - (byte) 0x29, (byte) 0xb5, (byte) 0x94, (byte) 0x52, - (byte) 0x4a, (byte) 0xad, (byte) 0xc5, (byte) 0xd8, - (byte) 0x62, (byte) 0x29, (byte) 0xad, (byte) 0xa4, - (byte) 0x94, (byte) 0x52, (byte) 0x4a, (byte) 0x29, - (byte) 0xa5, (byte) 0xd6, (byte) 0x52, (byte) 0x6c, - (byte) 0xad, (byte) 0xb5, (byte) 0xd8, (byte) 0x52, - (byte) 0x4a, (byte) 0x29, (byte) 0xa5, (byte) 0x96, - (byte) 0x5a, (byte) 0x4a, (byte) 0x29, (byte) 0xb5, - (byte) 0x16, (byte) 0x5b, (byte) 0x6a, (byte) 0x2d, - (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x4b, - (byte) 0x29, (byte) 0xa5, (byte) 0x96, (byte) 0x52, - (byte) 0x4b, (byte) 0x2d, (byte) 0xc6, (byte) 0xd6, - (byte) 0x5a, (byte) 0x4b, (byte) 0x29, (byte) 0xa5, - (byte) 0xd4, (byte) 0x52, (byte) 0x6a, (byte) 0xa9, - (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x6c, - (byte) 0xad, (byte) 0xb5, (byte) 0x98, (byte) 0x52, - (byte) 0x6a, (byte) 0x2d, (byte) 0xa5, (byte) 0xd4, - (byte) 0x52, (byte) 0x6b, (byte) 0x2d, (byte) 0xb5, - (byte) 0xd8, (byte) 0x52, (byte) 0x6a, (byte) 0x2d, - (byte) 0xb5, (byte) 0x94, (byte) 0x52, (byte) 0x6b, - (byte) 0xa9, (byte) 0xa5, (byte) 0x94, (byte) 0x5a, - (byte) 0x6b, (byte) 0x2d, (byte) 0xb6, (byte) 0xd8, - (byte) 0x5a, (byte) 0x6b, (byte) 0x29, (byte) 0xb5, - (byte) 0x94, (byte) 0x52, (byte) 0x4a, (byte) 0xa9, - (byte) 0xb5, (byte) 0x16, (byte) 0x5b, (byte) 0x8a, - (byte) 0xb1, (byte) 0xb5, (byte) 0xd4, (byte) 0x4a, - (byte) 0x4a, (byte) 0x29, (byte) 0xb5, (byte) 0xd4, - (byte) 0x5a, (byte) 0x6a, (byte) 0x2d, (byte) 0xb6, - (byte) 0x16, (byte) 0x5b, (byte) 0x6b, (byte) 0xad, - (byte) 0xa5, (byte) 0xd6, (byte) 0x5a, (byte) 0x6a, - (byte) 0x29, (byte) 0xa5, (byte) 0x16, (byte) 0x5b, - (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x16, - (byte) 0x63, (byte) 0x6b, (byte) 0x31, (byte) 0xa5, - (byte) 0x94, (byte) 0x52, (byte) 0x4b, (byte) 0xa9, - (byte) 0xa5, (byte) 0x02, (byte) 0x00, (byte) 0x80, - (byte) 0x0e, (byte) 0x1c, (byte) 0x00, (byte) 0x00, - (byte) 0x02, (byte) 0x8c, (byte) 0xa8, (byte) 0xb4, - (byte) 0x10, (byte) 0x3b, (byte) 0xcd, (byte) 0xb8, - (byte) 0xf2, (byte) 0x08, (byte) 0x1c, (byte) 0x51, - (byte) 0xc8, (byte) 0x30, (byte) 0x01, (byte) 0x15, - (byte) 0x1a, (byte) 0xb2, (byte) 0x12, (byte) 0x00, - (byte) 0x20, (byte) 0x03, (byte) 0x00, (byte) 0x20, - (byte) 0x90, (byte) 0x69, (byte) 0x92, (byte) 0x39, - (byte) 0x49, (byte) 0xa9, (byte) 0x11, (byte) 0x26, - (byte) 0x39, (byte) 0xc5, (byte) 0xa0, (byte) 0x94, - (byte) 0xe6, (byte) 0x9c, (byte) 0x53, (byte) 0x4a, - (byte) 0x29, (byte) 0xa5, (byte) 0x34, (byte) 0x44, - (byte) 0x96, (byte) 0x64, (byte) 0x90, (byte) 0x62, - (byte) 0x50, (byte) 0x1d, (byte) 0x99, (byte) 0x8c, - (byte) 0x39, (byte) 0x49, (byte) 0x39, (byte) 0x43, - (byte) 0xa4, (byte) 0x31, (byte) 0xa4, (byte) 0x20, - (byte) 0xf5, (byte) 0x4c, (byte) 0x91, (byte) 0xc7, - (byte) 0x94, (byte) 0x62, (byte) 0x10, (byte) 0x43, - (byte) 0x48, (byte) 0x2a, (byte) 0x74, (byte) 0x8a, - (byte) 0x39, (byte) 0x6c, (byte) 0x35, (byte) 0xf9, - (byte) 0x58, (byte) 0x42, (byte) 0x07, (byte) 0xb1, - (byte) 0x06, (byte) 0x65, (byte) 0x8c, (byte) 0x70, - (byte) 0x29, (byte) 0xc5, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x08, (byte) 0x02, (byte) 0x00, - (byte) 0x04, (byte) 0x84, (byte) 0x04, (byte) 0x00, - (byte) 0x18, (byte) 0x20, (byte) 0x28, (byte) 0x98, - (byte) 0x01, (byte) 0x00, (byte) 0x06, (byte) 0x07, - (byte) 0x08, (byte) 0x23, (byte) 0x07, (byte) 0x02, - (byte) 0x1d, (byte) 0x01, (byte) 0x04, (byte) 0x0e, - (byte) 0x6d, (byte) 0x00, (byte) 0x80, (byte) 0x81, - (byte) 0x08, (byte) 0x99, (byte) 0x09, (byte) 0x0c, - (byte) 0x0a, (byte) 0xa1, (byte) 0xc1, (byte) 0x41, - (byte) 0x26, (byte) 0x00, (byte) 0x3c, (byte) 0x40, - (byte) 0x44, (byte) 0x48, (byte) 0x05, (byte) 0x00, - (byte) 0x89, (byte) 0x09, (byte) 0x8a, (byte) 0xd2, - (byte) 0x85, (byte) 0x2e, (byte) 0x08, (byte) 0x21, - (byte) 0x82, (byte) 0x74, (byte) 0x11, (byte) 0x64, - (byte) 0xf1, (byte) 0xc0, (byte) 0x85, (byte) 0x13, - (byte) 0x37, (byte) 0x9e, (byte) 0xb8, (byte) 0xe1, - (byte) 0x84, (byte) 0x0e, (byte) 0x6d, (byte) 0x20, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x20, (byte) 0x00, (byte) 0xf0, - (byte) 0x01, (byte) 0x00, (byte) 0x90, (byte) 0x50, - (byte) 0x00, (byte) 0x11, (byte) 0x11, (byte) 0xd1, - (byte) 0xcc, (byte) 0x55, (byte) 0x58, (byte) 0x5c, - (byte) 0x60, (byte) 0x64, (byte) 0x68, (byte) 0x6c, - (byte) 0x70, (byte) 0x74, (byte) 0x78, (byte) 0x7c, - (byte) 0x80, (byte) 0x84, (byte) 0x08, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x10, (byte) 0x00, (byte) 0x7c, (byte) 0x00, - (byte) 0x00, (byte) 0x24, (byte) 0x22, (byte) 0x40, - (byte) 0x44, (byte) 0x44, (byte) 0x34, (byte) 0x73, - (byte) 0x15, (byte) 0x16, (byte) 0x17, (byte) 0x18, - (byte) 0x19, (byte) 0x1a, (byte) 0x1b, (byte) 0x1c, - (byte) 0x1d, (byte) 0x1e, (byte) 0x1f, (byte) 0x20, - (byte) 0x21, (byte) 0x01, (byte) 0x00, (byte) 0x80, - (byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x00, (byte) 0x20, (byte) 0x80, - (byte) 0x00, (byte) 0x04, (byte) 0x04, (byte) 0x04, - (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x00, - (byte) 0x00, (byte) 0x04, (byte) 0x04 - }; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java index 587d8a75a7..5895116e7d 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java @@ -19,11 +19,13 @@ import static com.google.android.exoplayer2.extractor.ogg.VorbisReader.readBits; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ogg.VorbisReader.VorbisSetup; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput.SimulatedIOException; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; import org.junit.Test; @@ -55,7 +57,11 @@ public final class VorbisReaderTest { @Test public void testReadSetupHeadersWithIOExceptions() throws IOException, InterruptedException { - byte[] data = OggTestData.getVorbisHeaderPages(); + // initial two pages of bytes which by spec contain the three Vorbis header packets: + // identification, comment and setup header. + byte[] data = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "binary/ogg/vorbis_header_pages"); ExtractorInput input = new FakeExtractorInput.Builder().setData(data).setSimulateIOErrors(true) .setSimulateUnknownLength(true).setSimulatePartialReads(true).build(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java index 72a80161f2..d3d3e53458 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java @@ -35,7 +35,18 @@ public final class FlacStreamMetadataTest { commentsList.add("Artist=Singer"); Metadata metadata = - new FlacStreamMetadata(0, 0, 0, 0, 0, 0, 0, 0, commentsList, new ArrayList<>()).metadata; + new FlacStreamMetadata( + /* minBlockSizeSamples= */ 0, + /* maxBlockSizeSamples= */ 0, + /* minFrameSize= */ 0, + /* maxFrameSize= */ 0, + /* sampleRate= */ 0, + /* channels= */ 0, + /* bitsPerSample= */ 0, + /* totalSamples= */ 0, + commentsList, + /* pictureFrames= */ new ArrayList<>()) + .getMetadataCopyWithAppendedEntriesFrom(/* other= */ null); assertThat(metadata.length()).isEqualTo(2); VorbisComment commentFrame = (VorbisComment) metadata.get(0); @@ -51,9 +62,20 @@ public final class FlacStreamMetadataTest { ArrayList commentsList = new ArrayList<>(); Metadata metadata = - new FlacStreamMetadata(0, 0, 0, 0, 0, 0, 0, 0, commentsList, new ArrayList<>()).metadata; + new FlacStreamMetadata( + /* minBlockSizeSamples= */ 0, + /* maxBlockSizeSamples= */ 0, + /* minFrameSize= */ 0, + /* maxFrameSize= */ 0, + /* sampleRate= */ 0, + /* channels= */ 0, + /* bitsPerSample= */ 0, + /* totalSamples= */ 0, + commentsList, + /* pictureFrames= */ new ArrayList<>()) + .getMetadataCopyWithAppendedEntriesFrom(/* other= */ null); - assertThat(metadata).isNull(); + assertThat(metadata.length()).isEqualTo(0); } @Test @@ -62,7 +84,18 @@ public final class FlacStreamMetadataTest { commentsList.add("Title=So=ng"); Metadata metadata = - new FlacStreamMetadata(0, 0, 0, 0, 0, 0, 0, 0, commentsList, new ArrayList<>()).metadata; + new FlacStreamMetadata( + /* minBlockSizeSamples= */ 0, + /* maxBlockSizeSamples= */ 0, + /* minFrameSize= */ 0, + /* maxFrameSize= */ 0, + /* sampleRate= */ 0, + /* channels= */ 0, + /* bitsPerSample= */ 0, + /* totalSamples= */ 0, + commentsList, + /* pictureFrames= */ new ArrayList<>()) + .getMetadataCopyWithAppendedEntriesFrom(/* other= */ null); assertThat(metadata.length()).isEqualTo(1); VorbisComment commentFrame = (VorbisComment) metadata.get(0); @@ -77,7 +110,18 @@ public final class FlacStreamMetadataTest { commentsList.add("Artist=Singer"); Metadata metadata = - new FlacStreamMetadata(0, 0, 0, 0, 0, 0, 0, 0, commentsList, new ArrayList<>()).metadata; + new FlacStreamMetadata( + /* minBlockSizeSamples= */ 0, + /* maxBlockSizeSamples= */ 0, + /* minFrameSize= */ 0, + /* maxFrameSize= */ 0, + /* sampleRate= */ 0, + /* channels= */ 0, + /* bitsPerSample= */ 0, + /* totalSamples= */ 0, + commentsList, + /* pictureFrames= */ new ArrayList<>()) + .getMetadataCopyWithAppendedEntriesFrom(/* other= */ null); assertThat(metadata.length()).isEqualTo(1); VorbisComment commentFrame = (VorbisComment) metadata.get(0); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java index 1ca4f1fb18..1e71e0a316 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java @@ -152,6 +152,7 @@ public final class ExtractorAsserts { assertOutput(factory.create(), file, data, context, false, false, false, false); } + // TODO: Assert format metadata [Internal ref: b/144771011]. /** * Asserts that {@code extractor} consumes {@code sampleFile} successfully and its output equals * to a prerecorded output dump file with the name {@code sampleFile} + "{@value From 9fec58a2ef6722e58f3ac8b8f2181f0cfba2c07b Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 22 Nov 2019 12:21:57 +0000 Subject: [PATCH 0035/1052] Add SSA test file to the demo app I wrote this myself PiperOrigin-RevId: 281942685 --- demos/main/src/main/assets/media.exolist.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 8550377ddf..4375bdf3a7 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -600,6 +600,13 @@ "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/netflix_ttml_sample.xml", "subtitle_mime_type": "application/ttml+xml", "subtitle_language": "en" + }, + { + "name": "SSA/ASS position & alignment", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ssa/test-subs-position.ass", + "subtitle_mime_type": "text/x-ssa", + "subtitle_language": "en" } ] } From 630992d05ba9957653b9eff372b0966a01be529b Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 3 Dec 2019 15:51:44 +0000 Subject: [PATCH 0036/1052] Allow no output frame holder BinarySearchSeeker PiperOrigin-RevId: 283544187 --- .../ext/flac/FlacBinarySearchSeekerTest.java | 17 +++++--- .../ext/flac/FlacBinarySearchSeeker.java | 28 ++++++++++--- .../exoplayer2/ext/flac/FlacExtractor.java | 28 ++++++++----- .../extractor/BinarySearchSeeker.java | 39 ++----------------- .../extractor/ts/PsBinarySearchSeeker.java | 3 +- .../exoplayer2/extractor/ts/PsExtractor.java | 3 +- .../extractor/ts/TsBinarySearchSeeker.java | 3 +- .../exoplayer2/extractor/ts/TsExtractor.java | 3 +- 8 files changed, 60 insertions(+), 64 deletions(-) diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java index 025fdfd209..a18202f4e2 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java @@ -20,10 +20,12 @@ import static org.junit.Assert.fail; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.ext.flac.FlacBinarySearchSeeker.OutputFrameHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.TestUtil; import java.io.IOException; +import java.nio.ByteBuffer; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -47,19 +49,20 @@ public final class FlacBinarySearchSeekerTest { throws IOException, FlacDecoderException, InterruptedException { byte[] data = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NOSEEKTABLE_FLAC); - FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); FlacDecoderJni decoderJni = new FlacDecoderJni(); decoderJni.setData(input); + OutputFrameHolder outputFrameHolder = new OutputFrameHolder(ByteBuffer.allocateDirect(0)); FlacBinarySearchSeeker seeker = new FlacBinarySearchSeeker( decoderJni.decodeStreamMetadata(), /* firstFramePosition= */ 0, data.length, - decoderJni); - + decoderJni, + outputFrameHolder); SeekMap seekMap = seeker.getSeekMap(); + assertThat(seekMap).isNotNull(); assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US); assertThat(seekMap.isSeekable()).isTrue(); @@ -70,18 +73,20 @@ public final class FlacBinarySearchSeekerTest { throws IOException, FlacDecoderException, InterruptedException { byte[] data = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NOSEEKTABLE_FLAC); - FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); FlacDecoderJni decoderJni = new FlacDecoderJni(); decoderJni.setData(input); + OutputFrameHolder outputFrameHolder = new OutputFrameHolder(ByteBuffer.allocateDirect(0)); + FlacBinarySearchSeeker seeker = new FlacBinarySearchSeeker( decoderJni.decodeStreamMetadata(), /* firstFramePosition= */ 0, data.length, - decoderJni); - + decoderJni, + outputFrameHolder); seeker.setSeekTargetUs(/* timeUs= */ 1000); + assertThat(seeker.isSeeking()).isTrue(); } } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java index 08f179152e..74c3e73791 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java @@ -31,16 +31,33 @@ import java.nio.ByteBuffer; */ /* package */ final class FlacBinarySearchSeeker extends BinarySearchSeeker { + /** + * Holds a frame extracted from a stream, together with the time stamp of the frame in + * microseconds. + */ + public static final class OutputFrameHolder { + + public final ByteBuffer byteBuffer; + public long timeUs; + + /** Constructs an instance, wrapping the given byte buffer. */ + public OutputFrameHolder(ByteBuffer outputByteBuffer) { + this.timeUs = 0; + this.byteBuffer = outputByteBuffer; + } + } + private final FlacDecoderJni decoderJni; public FlacBinarySearchSeeker( FlacStreamMetadata streamMetadata, long firstFramePosition, long inputLength, - FlacDecoderJni decoderJni) { + FlacDecoderJni decoderJni, + OutputFrameHolder outputFrameHolder) { super( new FlacSeekTimestampConverter(streamMetadata), - new FlacTimestampSeeker(decoderJni), + new FlacTimestampSeeker(decoderJni, outputFrameHolder), streamMetadata.getDurationUs(), /* floorTimePosition= */ 0, /* ceilingTimePosition= */ streamMetadata.totalSamples, @@ -63,14 +80,15 @@ import java.nio.ByteBuffer; private static final class FlacTimestampSeeker implements TimestampSeeker { private final FlacDecoderJni decoderJni; + private final OutputFrameHolder outputFrameHolder; - private FlacTimestampSeeker(FlacDecoderJni decoderJni) { + private FlacTimestampSeeker(FlacDecoderJni decoderJni, OutputFrameHolder outputFrameHolder) { this.decoderJni = decoderJni; + this.outputFrameHolder = outputFrameHolder; } @Override - public TimestampSearchResult searchForTimestamp( - ExtractorInput input, long targetSampleIndex, OutputFrameHolder outputFrameHolder) + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetSampleIndex) throws IOException, InterruptedException { ByteBuffer outputBuffer = outputFrameHolder.byteBuffer; long searchPosition = input.getPosition(); diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 6ea099064e..7c69a93fc9 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -21,7 +21,7 @@ import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.extractor.BinarySearchSeeker.OutputFrameHolder; +import com.google.android.exoplayer2.ext.flac.FlacBinarySearchSeeker.OutputFrameHolder; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -190,11 +190,12 @@ public final class FlacExtractor implements Extractor { return; } + FlacDecoderJni flacDecoderJni = decoderJni; FlacStreamMetadata streamMetadata; try { - streamMetadata = decoderJni.decodeStreamMetadata(); + streamMetadata = flacDecoderJni.decodeStreamMetadata(); } catch (IOException e) { - decoderJni.reset(/* newPosition= */ 0); + flacDecoderJni.reset(/* newPosition= */ 0); input.setRetryPosition(/* position= */ 0, e); throw e; } @@ -202,12 +203,17 @@ public final class FlacExtractor implements Extractor { streamMetadataDecoded = true; if (this.streamMetadata == null) { this.streamMetadata = streamMetadata; - binarySearchSeeker = - outputSeekMap(decoderJni, streamMetadata, input.getLength(), extractorOutput); - Metadata metadata = streamMetadata.getMetadataCopyWithAppendedEntriesFrom(id3Metadata); - outputFormat(streamMetadata, metadata, trackOutput); outputBuffer.reset(streamMetadata.getMaxDecodedFrameSize()); outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.data)); + binarySearchSeeker = + outputSeekMap( + flacDecoderJni, + streamMetadata, + input.getLength(), + extractorOutput, + outputFrameHolder); + Metadata metadata = streamMetadata.getMetadataCopyWithAppendedEntriesFrom(id3Metadata); + outputFormat(streamMetadata, metadata, trackOutput); } } @@ -219,7 +225,7 @@ public final class FlacExtractor implements Extractor { OutputFrameHolder outputFrameHolder, TrackOutput trackOutput) throws InterruptedException, IOException { - int seekResult = binarySearchSeeker.handlePendingSeek(input, seekPosition, outputFrameHolder); + int seekResult = binarySearchSeeker.handlePendingSeek(input, seekPosition); ByteBuffer outputByteBuffer = outputFrameHolder.byteBuffer; if (seekResult == RESULT_CONTINUE && outputByteBuffer.limit() > 0) { outputSample(outputBuffer, outputByteBuffer.limit(), outputFrameHolder.timeUs, trackOutput); @@ -236,7 +242,8 @@ public final class FlacExtractor implements Extractor { FlacDecoderJni decoderJni, FlacStreamMetadata streamMetadata, long streamLength, - ExtractorOutput output) { + ExtractorOutput output, + OutputFrameHolder outputFrameHolder) { boolean haveSeekTable = decoderJni.getSeekPoints(/* timeUs= */ 0) != null; FlacBinarySearchSeeker binarySearchSeeker = null; SeekMap seekMap; @@ -245,7 +252,8 @@ public final class FlacExtractor implements Extractor { } else if (streamLength != C.LENGTH_UNSET) { long firstFramePosition = decoderJni.getDecodePosition(); binarySearchSeeker = - new FlacBinarySearchSeeker(streamMetadata, firstFramePosition, streamLength, decoderJni); + new FlacBinarySearchSeeker( + streamMetadata, firstFramePosition, streamLength, decoderJni, outputFrameHolder); seekMap = binarySearchSeeker.getSeekMap(); } else { seekMap = new SeekMap.Unseekable(streamMetadata.getDurationUs()); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java index 06d3ed603e..0d823fa31d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java @@ -24,7 +24,6 @@ import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.nio.ByteBuffer; /** * A seeker that supports seeking within a stream by searching for the target frame using binary @@ -48,38 +47,17 @@ public abstract class BinarySearchSeeker { * * @param input The {@link ExtractorInput} from which data should be peeked. * @param targetTimestamp The target timestamp. - * @param outputFrameHolder If {@link TimestampSearchResult#TYPE_TARGET_TIMESTAMP_FOUND} is - * returned, this holder may be updated to hold the extracted frame that contains the target - * frame/sample associated with the target timestamp. * @return A {@link TimestampSearchResult} that describes the result of the search. * @throws IOException If an error occurred reading from the input. * @throws InterruptedException If the thread was interrupted. */ - TimestampSearchResult searchForTimestamp( - ExtractorInput input, long targetTimestamp, OutputFrameHolder outputFrameHolder) + TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) throws IOException, InterruptedException; /** Called when a seek operation finishes. */ default void onSeekFinished() {} } - /** - * Holds a frame extracted from a stream, together with the time stamp of the frame in - * microseconds. - */ - public static final class OutputFrameHolder { - - public final ByteBuffer byteBuffer; - - public long timeUs; - - /** Constructs an instance, wrapping the given byte buffer. */ - public OutputFrameHolder(ByteBuffer outputByteBuffer) { - this.timeUs = 0; - this.byteBuffer = outputByteBuffer; - } - } - /** * A {@link SeekTimestampConverter} implementation that returns the seek time itself as the * timestamp for a seek time position. @@ -189,15 +167,11 @@ public abstract class BinarySearchSeeker { * @param input The {@link ExtractorInput} from which data should be read. * @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated * to hold the position of the required seek. - * @param outputFrameHolder If {@link Extractor#RESULT_CONTINUE} is returned, this holder may be - * updated to hold the extracted frame that contains the target sample. The caller needs to - * check the byte buffer limit to see if an extracted frame is available. * @return One of the {@code RESULT_} values defined in {@link Extractor}. * @throws IOException If an error occurred reading from the input. * @throws InterruptedException If the thread was interrupted. */ - public int handlePendingSeek( - ExtractorInput input, PositionHolder seekPositionHolder, OutputFrameHolder outputFrameHolder) + public int handlePendingSeek(ExtractorInput input, PositionHolder seekPositionHolder) throws InterruptedException, IOException { TimestampSeeker timestampSeeker = Assertions.checkNotNull(this.timestampSeeker); while (true) { @@ -217,8 +191,7 @@ public abstract class BinarySearchSeeker { input.resetPeekPosition(); TimestampSearchResult timestampSearchResult = - timestampSeeker.searchForTimestamp( - input, seekOperationParams.getTargetTimePosition(), outputFrameHolder); + timestampSeeker.searchForTimestamp(input, seekOperationParams.getTargetTimePosition()); switch (timestampSearchResult.type) { case TimestampSearchResult.TYPE_POSITION_OVERESTIMATED: @@ -419,7 +392,7 @@ public abstract class BinarySearchSeeker { /** * Represents possible search results for {@link - * TimestampSeeker#searchForTimestamp(ExtractorInput, long, OutputFrameHolder)}. + * TimestampSeeker#searchForTimestamp(ExtractorInput, long)}. */ public static final class TimestampSearchResult { @@ -495,10 +468,6 @@ public abstract class BinarySearchSeeker { /** * Returns a result to signal that the target timestamp has been found at {@code * resultBytePosition}, and the seek operation can stop. - * - *

    Note that when this value is returned from {@link - * TimestampSeeker#searchForTimestamp(ExtractorInput, long, OutputFrameHolder)}, the {@link - * OutputFrameHolder} may be updated to hold the target frame as an optimization. */ public static TimestampSearchResult targetFoundResult(long resultBytePosition) { return new TimestampSearchResult( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java index 4efd38b7eb..c4f53ba176 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java @@ -69,8 +69,7 @@ import java.io.IOException; } @Override - public TimestampSearchResult searchForTimestamp( - ExtractorInput input, long targetTimestamp, OutputFrameHolder outputFrameHolder) + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) throws IOException, InterruptedException { long inputPosition = input.getPosition(); int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java index f453a9cc43..fec108fd5f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java @@ -168,8 +168,7 @@ public final class PsExtractor implements Extractor { } maybeOutputSeekMap(inputLength); if (psBinarySearchSeeker != null && psBinarySearchSeeker.isSeeking()) { - return psBinarySearchSeeker.handlePendingSeek( - input, seekPosition, /* outputFrameHolder= */ null); + return psBinarySearchSeeker.handlePendingSeek(input, seekPosition); } input.resetPeekPosition(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java index ea2519d2e9..a627c00ba2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java @@ -73,8 +73,7 @@ import java.io.IOException; } @Override - public TimestampSearchResult searchForTimestamp( - ExtractorInput input, long targetTimestamp, OutputFrameHolder outputFrameHolder) + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) throws IOException, InterruptedException { long inputPosition = input.getPosition(); int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 04dd7df385..2cd7398d7c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -268,8 +268,7 @@ public final class TsExtractor implements Extractor { } if (tsBinarySearchSeeker != null && tsBinarySearchSeeker.isSeeking()) { - return tsBinarySearchSeeker.handlePendingSeek( - input, seekPosition, /* outputFrameHolder= */ null); + return tsBinarySearchSeeker.handlePendingSeek(input, seekPosition); } } From 9c23888f1c686278a648e7551bd8271827ae45c2 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 3 Dec 2019 16:34:24 +0000 Subject: [PATCH 0037/1052] Don't try to track buffersInCodec with tunneling PiperOrigin-RevId: 283551324 --- .../exoplayer2/video/MediaCodecVideoRenderer.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 57c3ab13fa..3625369c56 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -767,7 +767,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @CallSuper @Override protected void onQueueInputBuffer(DecoderInputBuffer buffer) { - buffersInCodecCount++; + // In tunneling mode the device may do frame rate conversion, so in general we can't keep track + // of the number of buffers in the codec. + if (!tunneling) { + buffersInCodecCount++; + } lastInputTimeUs = Math.max(buffer.timeUs, lastInputTimeUs); if (Util.SDK_INT < 23 && tunneling) { // In tunneled mode before API 23 we don't have a way to know when the buffer is output, so @@ -1012,7 +1016,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @CallSuper @Override protected void onProcessedOutputBuffer(long presentationTimeUs) { - buffersInCodecCount--; + if (!tunneling) { + buffersInCodecCount--; + } while (pendingOutputStreamOffsetCount != 0 && presentationTimeUs >= pendingOutputStreamSwitchTimesUs[0]) { outputStreamOffsetUs = pendingOutputStreamOffsetsUs[0]; From 9974670cc7ed0c908d3a5507424a0af6d04c7eb5 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 4 Dec 2019 19:18:56 +0000 Subject: [PATCH 0038/1052] Make DownloadHelper pass DrmSessionManager to MediaSources PiperOrigin-RevId: 283795201 --- .../exoplayer2/demo/PlayerActivity.java | 13 ++++- .../exoplayer2/offline/DownloadHelper.java | 49 ++++++++++++++++--- .../source/ExtractorMediaSource.java | 7 +++ .../exoplayer2/source/MediaSourceFactory.java | 11 +++++ .../source/ProgressiveMediaSource.java | 29 +++++------ .../source/dash/DashMediaSource.java | 29 +++++------ .../exoplayer2/source/hls/HlsMediaSource.java | 29 +++++------ .../source/smoothstreaming/SsMediaSource.java | 29 +++++------ 8 files changed, 131 insertions(+), 65 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 2f8d0045d3..11a4b7216b 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -615,10 +615,21 @@ public class PlayerActivity extends AppCompatActivity } MediaSourceFactory adMediaSourceFactory = new MediaSourceFactory() { + + private DrmSessionManager drmSessionManager = + DrmSessionManager.getDummyDrmSessionManager(); + + @Override + @SuppressWarnings("unchecked") // Safe upcasting. + public MediaSourceFactory setDrmSessionManager(DrmSessionManager drmSessionManager) { + this.drmSessionManager = (DrmSessionManager) drmSessionManager; + return this; + } + @Override public MediaSource createMediaSource(Uri uri) { return PlayerActivity.this.createLeafMediaSource( - uri, /* extension=*/ null, DrmSessionManager.getDummyDrmSessionManager()); + uri, /* extension=*/ null, drmSessionManager); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index c585c79b76..b2641552c0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -263,9 +263,13 @@ public final class DownloadHelper { uri, /* cacheKey= */ null, createMediaSourceInternal( - DASH_FACTORY_CONSTRUCTOR, uri, dataSourceFactory, /* streamKeys= */ null), + DASH_FACTORY_CONSTRUCTOR, + uri, + dataSourceFactory, + drmSessionManager, + /* streamKeys= */ null), trackSelectorParameters, - Util.getRendererCapabilities(renderersFactory, drmSessionManager)); + Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); } /** @deprecated Use {@link #forHls(Context, Uri, Factory, RenderersFactory)} */ @@ -329,9 +333,13 @@ public final class DownloadHelper { uri, /* cacheKey= */ null, createMediaSourceInternal( - HLS_FACTORY_CONSTRUCTOR, uri, dataSourceFactory, /* streamKeys= */ null), + HLS_FACTORY_CONSTRUCTOR, + uri, + dataSourceFactory, + drmSessionManager, + /* streamKeys= */ null), trackSelectorParameters, - Util.getRendererCapabilities(renderersFactory, drmSessionManager)); + Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); } /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */ @@ -395,9 +403,24 @@ public final class DownloadHelper { uri, /* cacheKey= */ null, createMediaSourceInternal( - SS_FACTORY_CONSTRUCTOR, uri, dataSourceFactory, /* streamKeys= */ null), + SS_FACTORY_CONSTRUCTOR, + uri, + dataSourceFactory, + drmSessionManager, + /* streamKeys= */ null), trackSelectorParameters, - Util.getRendererCapabilities(renderersFactory, drmSessionManager)); + Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); + } + + /** + * Equivalent to {@link #createMediaSource(DownloadRequest, Factory, DrmSessionManager) + * createMediaSource(downloadRequest, dataSourceFactory, + * DrmSessionManager.getDummyDrmSessionManager())}. + */ + public static MediaSource createMediaSource( + DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory) { + return createMediaSource( + downloadRequest, dataSourceFactory, DrmSessionManager.getDummyDrmSessionManager()); } /** @@ -409,7 +432,9 @@ public final class DownloadHelper { * @return A MediaSource which only contains the tracks defined in {@code downloadRequest}. */ public static MediaSource createMediaSource( - DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory) { + DownloadRequest downloadRequest, + DataSource.Factory dataSourceFactory, + DrmSessionManager drmSessionManager) { @Nullable Constructor constructor; switch (downloadRequest.type) { case DownloadRequest.TYPE_DASH: @@ -428,7 +453,11 @@ public final class DownloadHelper { throw new IllegalStateException("Unsupported type: " + downloadRequest.type); } return createMediaSourceInternal( - constructor, downloadRequest.uri, dataSourceFactory, downloadRequest.streamKeys); + constructor, + downloadRequest.uri, + dataSourceFactory, + drmSessionManager, + downloadRequest.streamKeys); } private final String downloadType; @@ -888,12 +917,16 @@ public final class DownloadHelper { @Nullable Constructor constructor, Uri uri, Factory dataSourceFactory, + @Nullable DrmSessionManager drmSessionManager, @Nullable List streamKeys) { if (constructor == null) { throw new IllegalStateException("Module missing to create media source."); } try { MediaSourceFactory factory = constructor.newInstance(dataSourceFactory); + if (drmSessionManager != null) { + factory.setDrmSessionManager(drmSessionManager); + } if (streamKeys != null) { factory.setStreamKeys(streamKeys); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java index 060027fee7..830a62a884 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java @@ -179,6 +179,13 @@ public final class ExtractorMediaSource extends CompositeMediaSource { return this; } + /** @deprecated Use {@link ProgressiveMediaSource.Factory#setDrmSessionManager} instead. */ + @Override + @Deprecated + public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { + throw new UnsupportedOperationException(); + } + /** * Returns a new {@link ExtractorMediaSource} using the current parameters. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java index c53abd1235..201f241d59 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java @@ -17,6 +17,8 @@ package com.google.android.exoplayer2.source; import android.net.Uri; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.StreamKey; import java.util.List; @@ -34,6 +36,15 @@ public interface MediaSourceFactory { return this; } + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + MediaSourceFactory setDrmSessionManager(DrmSessionManager drmSessionManager); + /** * Creates a new {@link MediaSource} with the specified {@code uri}. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index c88972da62..f05b576acb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -132,20 +132,6 @@ public final class ProgressiveMediaSource extends BaseMediaSource return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. - */ - public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { - Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; - return this; - } - /** * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. @@ -177,6 +163,21 @@ public final class ProgressiveMediaSource extends BaseMediaSource return this; } + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + /** * Returns a new {@link ProgressiveMediaSource} using the current parameters. * diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 3f179d0e7e..dfcd62b8b1 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -136,20 +136,6 @@ public final class DashMediaSource extends BaseMediaSource { return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. - */ - public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { - Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; - return this; - } - /** * Sets the minimum number of times to retry if a loading error occurs. See {@link * #setLoadErrorHandlingPolicy} for the default value. @@ -310,6 +296,21 @@ public final class DashMediaSource extends BaseMediaSource { return mediaSource; } + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + /** * Returns a new {@link DashMediaSource} using the current parameters. * diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index db52fa1c02..16dedb6c21 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -160,20 +160,6 @@ public final class HlsMediaSource extends BaseMediaSource return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. - */ - public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { - Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; - return this; - } - /** * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. @@ -326,6 +312,21 @@ public final class HlsMediaSource extends BaseMediaSource return mediaSource; } + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + /** * Returns a new {@link HlsMediaSource} using the current parameters. * diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 4c05353186..8cc848dfa4 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -121,20 +121,6 @@ public final class SsMediaSource extends BaseMediaSource return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. - */ - public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { - Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; - return this; - } - /** * Sets the minimum number of times to retry if a loading error occurs. See {@link * #setLoadErrorHandlingPolicy} for the default value. @@ -276,6 +262,21 @@ public final class SsMediaSource extends BaseMediaSource return mediaSource; } + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + /** * Returns a new {@link SsMediaSource} using the current parameters. * From 14897fb6dfffe7c99fb9150d4bcf5e89b3057303 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 4 Dec 2019 19:21:55 +0000 Subject: [PATCH 0039/1052] Invert the ownership scheme between SampleQueue and SampleMetadataQueue Saves around 200 lines of code. High level overview: - Rename SampleQueue to SampleDataQueue. - Rename SampleMetadataQueue to SampleQueue. This CL should not introduce behavior changes. The only significant changes in synchronization should be: + Add synchronized keyword to isReady. - Seems to be necessary. + Add synchronized keyword to SampleQueue.sampleMetadata. - Before this change, SampleQueue.sampleMetadata could acquire the SampleMetadataQueue lock three times in a single method call. Other miscellaneous improvements: + Put all private methods at the bottom. + Move release() to the right category. PiperOrigin-RevId: 283795844 --- .../exoplayer2/source/SampleDataQueue.java | 466 +++++++ .../source/SampleMetadataQueue.java | 721 ----------- .../exoplayer2/source/SampleQueue.java | 1071 +++++++++-------- 3 files changed, 1039 insertions(+), 1219 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java new file mode 100644 index 0000000000..68761cef19 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java @@ -0,0 +1,466 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.TrackOutput.CryptoData; +import com.google.android.exoplayer2.source.SampleQueue.SampleExtrasHolder; +import com.google.android.exoplayer2.upstream.Allocation; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** A queue of media sample data. */ +/* package */ class SampleDataQueue { + + private static final int INITIAL_SCRATCH_SIZE = 32; + + private final Allocator allocator; + private final int allocationLength; + private final ParsableByteArray scratch; + + // References into the linked list of allocations. + private AllocationNode firstAllocationNode; + private AllocationNode readAllocationNode; + private AllocationNode writeAllocationNode; + + // Accessed only by the loading thread (or the consuming thread when there is no loading thread). + private long totalBytesWritten; + + public SampleDataQueue(Allocator allocator) { + this.allocator = allocator; + allocationLength = allocator.getIndividualAllocationLength(); + scratch = new ParsableByteArray(INITIAL_SCRATCH_SIZE); + firstAllocationNode = new AllocationNode(/* startPosition= */ 0, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** Clears all sample data. */ + public void reset() { + clearAllocationNodes(firstAllocationNode); + firstAllocationNode = new AllocationNode(0, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + totalBytesWritten = 0; + allocator.trim(); + } + + /** + * Discards sample data bytes from the write side of the queue. + * + * @param totalBytesWritten The reduced total number of bytes written after the samples have been + * discarded, or 0 if the queue is now empty. + */ + public void discardUpstreamSampleBytes(long totalBytesWritten) { + this.totalBytesWritten = totalBytesWritten; + if (this.totalBytesWritten == 0 + || this.totalBytesWritten == firstAllocationNode.startPosition) { + clearAllocationNodes(firstAllocationNode); + firstAllocationNode = new AllocationNode(this.totalBytesWritten, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + } else { + // Find the last node containing at least 1 byte of data that we need to keep. + AllocationNode lastNodeToKeep = firstAllocationNode; + while (this.totalBytesWritten > lastNodeToKeep.endPosition) { + lastNodeToKeep = lastNodeToKeep.next; + } + // Discard all subsequent nodes. + AllocationNode firstNodeToDiscard = lastNodeToKeep.next; + clearAllocationNodes(firstNodeToDiscard); + // Reset the successor of the last node to be an uninitialized node. + lastNodeToKeep.next = new AllocationNode(lastNodeToKeep.endPosition, allocationLength); + // Update writeAllocationNode and readAllocationNode as necessary. + writeAllocationNode = + this.totalBytesWritten == lastNodeToKeep.endPosition + ? lastNodeToKeep.next + : lastNodeToKeep; + if (readAllocationNode == firstNodeToDiscard) { + readAllocationNode = lastNodeToKeep.next; + } + } + } + + // Called by the consuming thread. + + /** Rewinds the read position to the first sample in the queue. */ + public void rewind() { + readAllocationNode = firstAllocationNode; + } + + /** + * Reads data from the rolling buffer to populate a decoder input buffer. + * + * @param buffer The buffer to populate. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + public void readToBuffer(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { + // Read encryption data if the sample is encrypted. + if (buffer.isEncrypted()) { + readEncryptionData(buffer, extrasHolder); + } + // Read sample data, extracting supplemental data into a separate buffer if needed. + if (buffer.hasSupplementalData()) { + // If there is supplemental data, the sample data is prefixed by its size. + scratch.reset(4); + readData(extrasHolder.offset, scratch.data, 4); + int sampleSize = scratch.readUnsignedIntToInt(); + extrasHolder.offset += 4; + extrasHolder.size -= 4; + + // Write the sample data. + buffer.ensureSpaceForWrite(sampleSize); + readData(extrasHolder.offset, buffer.data, sampleSize); + extrasHolder.offset += sampleSize; + extrasHolder.size -= sampleSize; + + // Write the remaining data as supplemental data. + buffer.resetSupplementalData(extrasHolder.size); + readData(extrasHolder.offset, buffer.supplementalData, extrasHolder.size); + } else { + // Write the sample data. + buffer.ensureSpaceForWrite(extrasHolder.size); + readData(extrasHolder.offset, buffer.data, extrasHolder.size); + } + } + + /** + * Advances the read position to the specified absolute position. + * + * @param absolutePosition The new absolute read position. May be {@link C#POSITION_UNSET}, in + * which case calling this method is a no-op. + */ + public void discardDownstreamTo(long absolutePosition) { + if (absolutePosition == C.POSITION_UNSET) { + return; + } + while (absolutePosition >= firstAllocationNode.endPosition) { + // Advance firstAllocationNode to the specified absolute position. Also clear nodes that are + // advanced past, and return their underlying allocations to the allocator. + allocator.release(firstAllocationNode.allocation); + firstAllocationNode = firstAllocationNode.clear(); + } + if (readAllocationNode.startPosition < firstAllocationNode.startPosition) { + // We discarded the node referenced by readAllocationNode. We need to advance it to the first + // remaining node. + readAllocationNode = firstAllocationNode; + } + } + + // Called by the loading thread. + + public long getTotalBytesWritten() { + return totalBytesWritten; + } + + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + length = preAppend(length); + int bytesAppended = + input.read( + writeAllocationNode.allocation.data, + writeAllocationNode.translateOffset(totalBytesWritten), + length); + if (bytesAppended == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } + throw new EOFException(); + } + postAppend(bytesAppended); + return bytesAppended; + } + + public void sampleData(ParsableByteArray buffer, int length) { + while (length > 0) { + int bytesAppended = preAppend(length); + buffer.readBytes( + writeAllocationNode.allocation.data, + writeAllocationNode.translateOffset(totalBytesWritten), + bytesAppended); + length -= bytesAppended; + postAppend(bytesAppended); + } + } + + // Private methods. + + /** + * Reads encryption data for the current sample. + * + *

    The encryption data is written into {@link DecoderInputBuffer#cryptoInfo}, and {@link + * SampleExtrasHolder#size} is adjusted to subtract the number of bytes that were read. The same + * value is added to {@link SampleExtrasHolder#offset}. + * + * @param buffer The buffer into which the encryption data should be written. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { + long offset = extrasHolder.offset; + + // Read the signal byte. + scratch.reset(1); + readData(offset, scratch.data, 1); + offset++; + byte signalByte = scratch.data[0]; + boolean subsampleEncryption = (signalByte & 0x80) != 0; + int ivSize = signalByte & 0x7F; + + // Read the initialization vector. + if (buffer.cryptoInfo.iv == null) { + buffer.cryptoInfo.iv = new byte[16]; + } + readData(offset, buffer.cryptoInfo.iv, ivSize); + offset += ivSize; + + // Read the subsample count, if present. + int subsampleCount; + if (subsampleEncryption) { + scratch.reset(2); + readData(offset, scratch.data, 2); + offset += 2; + subsampleCount = scratch.readUnsignedShort(); + } else { + subsampleCount = 1; + } + + // Write the clear and encrypted subsample sizes. + int[] clearDataSizes = buffer.cryptoInfo.numBytesOfClearData; + if (clearDataSizes == null || clearDataSizes.length < subsampleCount) { + clearDataSizes = new int[subsampleCount]; + } + int[] encryptedDataSizes = buffer.cryptoInfo.numBytesOfEncryptedData; + if (encryptedDataSizes == null || encryptedDataSizes.length < subsampleCount) { + encryptedDataSizes = new int[subsampleCount]; + } + if (subsampleEncryption) { + int subsampleDataLength = 6 * subsampleCount; + scratch.reset(subsampleDataLength); + readData(offset, scratch.data, subsampleDataLength); + offset += subsampleDataLength; + scratch.setPosition(0); + for (int i = 0; i < subsampleCount; i++) { + clearDataSizes[i] = scratch.readUnsignedShort(); + encryptedDataSizes[i] = scratch.readUnsignedIntToInt(); + } + } else { + clearDataSizes[0] = 0; + encryptedDataSizes[0] = extrasHolder.size - (int) (offset - extrasHolder.offset); + } + + // Populate the cryptoInfo. + CryptoData cryptoData = extrasHolder.cryptoData; + buffer.cryptoInfo.set( + subsampleCount, + clearDataSizes, + encryptedDataSizes, + cryptoData.encryptionKey, + buffer.cryptoInfo.iv, + cryptoData.cryptoMode, + cryptoData.encryptedBlocks, + cryptoData.clearBlocks); + + // Adjust the offset and size to take into account the bytes read. + int bytesRead = (int) (offset - extrasHolder.offset); + extrasHolder.offset += bytesRead; + extrasHolder.size -= bytesRead; + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The buffer into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, ByteBuffer target, int length) { + advanceReadTo(absolutePosition); + int remaining = length; + while (remaining > 0) { + int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + Allocation allocation = readAllocationNode.allocation; + target.put(allocation.data, readAllocationNode.translateOffset(absolutePosition), toCopy); + remaining -= toCopy; + absolutePosition += toCopy; + if (absolutePosition == readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The array into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, byte[] target, int length) { + advanceReadTo(absolutePosition); + int remaining = length; + while (remaining > 0) { + int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + Allocation allocation = readAllocationNode.allocation; + System.arraycopy( + allocation.data, + readAllocationNode.translateOffset(absolutePosition), + target, + length - remaining, + toCopy); + remaining -= toCopy; + absolutePosition += toCopy; + if (absolutePosition == readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + } + + /** + * Advances the read position to the specified absolute position. + * + * @param absolutePosition The position to which {@link #readAllocationNode} should be advanced. + */ + private void advanceReadTo(long absolutePosition) { + while (absolutePosition >= readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + + /** + * Clears allocation nodes starting from {@code fromNode}. + * + * @param fromNode The node from which to clear. + */ + private void clearAllocationNodes(AllocationNode fromNode) { + if (!fromNode.wasInitialized) { + return; + } + // Bulk release allocations for performance (it's significantly faster when using + // DefaultAllocator because the allocator's lock only needs to be acquired and released once) + // [Internal: See b/29542039]. + int allocationCount = + (writeAllocationNode.wasInitialized ? 1 : 0) + + ((int) (writeAllocationNode.startPosition - fromNode.startPosition) + / allocationLength); + Allocation[] allocationsToRelease = new Allocation[allocationCount]; + AllocationNode currentNode = fromNode; + for (int i = 0; i < allocationsToRelease.length; i++) { + allocationsToRelease[i] = currentNode.allocation; + currentNode = currentNode.clear(); + } + allocator.release(allocationsToRelease); + } + + /** + * Called before writing sample data to {@link #writeAllocationNode}. May cause {@link + * #writeAllocationNode} to be initialized. + * + * @param length The number of bytes that the caller wishes to write. + * @return The number of bytes that the caller is permitted to write, which may be less than + * {@code length}. + */ + private int preAppend(int length) { + if (!writeAllocationNode.wasInitialized) { + writeAllocationNode.initialize( + allocator.allocate(), + new AllocationNode(writeAllocationNode.endPosition, allocationLength)); + } + return Math.min(length, (int) (writeAllocationNode.endPosition - totalBytesWritten)); + } + + /** + * Called after writing sample data. May cause {@link #writeAllocationNode} to be advanced. + * + * @param length The number of bytes that were written. + */ + private void postAppend(int length) { + totalBytesWritten += length; + if (totalBytesWritten == writeAllocationNode.endPosition) { + writeAllocationNode = writeAllocationNode.next; + } + } + + /** A node in a linked list of {@link Allocation}s held by the output. */ + private static final class AllocationNode { + + /** The absolute position of the start of the data (inclusive). */ + public final long startPosition; + /** The absolute position of the end of the data (exclusive). */ + public final long endPosition; + /** Whether the node has been initialized. Remains true after {@link #clear()}. */ + public boolean wasInitialized; + /** The {@link Allocation}, or {@code null} if the node is not initialized. */ + @Nullable public Allocation allocation; + /** + * The next {@link AllocationNode} in the list, or {@code null} if the node has not been + * initialized. Remains set after {@link #clear()}. + */ + @Nullable public AllocationNode next; + + /** + * @param startPosition See {@link #startPosition}. + * @param allocationLength The length of the {@link Allocation} with which this node will be + * initialized. + */ + public AllocationNode(long startPosition, int allocationLength) { + this.startPosition = startPosition; + this.endPosition = startPosition + allocationLength; + } + + /** + * Initializes the node. + * + * @param allocation The node's {@link Allocation}. + * @param next The next {@link AllocationNode}. + */ + public void initialize(Allocation allocation, AllocationNode next) { + this.allocation = allocation; + this.next = next; + wasInitialized = true; + } + + /** + * Gets the offset into the {@link #allocation}'s {@link Allocation#data} that corresponds to + * the specified absolute position. + * + * @param absolutePosition The absolute position. + * @return The corresponding offset into the allocation's data. + */ + public int translateOffset(long absolutePosition) { + return (int) (absolutePosition - startPosition) + allocation.offset; + } + + /** + * Clears {@link #allocation} and {@link #next}. + * + * @return The cleared next {@link AllocationNode}. + */ + public AllocationNode clear() { + allocation = null; + AllocationNode temp = next; + next = null; + return temp; + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java deleted file mode 100644 index bb578ddec7..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java +++ /dev/null @@ -1,721 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.source; - -import android.os.Looper; -import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.FormatHolder; -import com.google.android.exoplayer2.decoder.DecoderInputBuffer; -import com.google.android.exoplayer2.drm.DrmInitData; -import com.google.android.exoplayer2.drm.DrmSession; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.extractor.TrackOutput.CryptoData; -import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.MimeTypes; -import com.google.android.exoplayer2.util.Util; -import java.io.IOException; - -/** - * A queue of metadata describing the contents of a media buffer. - */ -/* package */ final class SampleMetadataQueue { - - /** - * A holder for sample metadata not held by {@link DecoderInputBuffer}. - */ - public static final class SampleExtrasHolder { - - public int size; - public long offset; - public CryptoData cryptoData; - - } - - private static final int SAMPLE_CAPACITY_INCREMENT = 1000; - - private final DrmSessionManager drmSessionManager; - - @Nullable private Format downstreamFormat; - @Nullable private DrmSession currentDrmSession; - - private int capacity; - private int[] sourceIds; - private long[] offsets; - private int[] sizes; - private int[] flags; - private long[] timesUs; - private CryptoData[] cryptoDatas; - private Format[] formats; - - private int length; - private int absoluteFirstIndex; - private int relativeFirstIndex; - private int readPosition; - - private long largestDiscardedTimestampUs; - private long largestQueuedTimestampUs; - private boolean isLastSampleQueued; - private boolean upstreamKeyframeRequired; - private boolean upstreamFormatRequired; - private Format upstreamFormat; - private Format upstreamCommittedFormat; - private int upstreamSourceId; - - public SampleMetadataQueue(DrmSessionManager drmSessionManager) { - this.drmSessionManager = drmSessionManager; - capacity = SAMPLE_CAPACITY_INCREMENT; - sourceIds = new int[capacity]; - offsets = new long[capacity]; - timesUs = new long[capacity]; - flags = new int[capacity]; - sizes = new int[capacity]; - cryptoDatas = new CryptoData[capacity]; - formats = new Format[capacity]; - largestDiscardedTimestampUs = Long.MIN_VALUE; - largestQueuedTimestampUs = Long.MIN_VALUE; - upstreamFormatRequired = true; - upstreamKeyframeRequired = true; - } - - // Called by the consuming thread, but only when there is no loading thread. - - /** - * Clears all sample metadata from the queue. - * - * @param resetUpstreamFormat Whether the upstream format should be cleared. If set to false, - * samples queued after the reset (and before a subsequent call to {@link #format(Format)}) - * are assumed to have the current upstream format. If set to true, {@link #format(Format)} - * must be called after the reset before any more samples can be queued. - */ - public void reset(boolean resetUpstreamFormat) { - length = 0; - absoluteFirstIndex = 0; - relativeFirstIndex = 0; - readPosition = 0; - upstreamKeyframeRequired = true; - largestDiscardedTimestampUs = Long.MIN_VALUE; - largestQueuedTimestampUs = Long.MIN_VALUE; - isLastSampleQueued = false; - upstreamCommittedFormat = null; - if (resetUpstreamFormat) { - upstreamFormat = null; - upstreamFormatRequired = true; - } - } - - /** - * Returns the current absolute write index. - */ - public int getWriteIndex() { - return absoluteFirstIndex + length; - } - - /** - * Discards samples from the write side of the queue. - * - * @param discardFromIndex The absolute index of the first sample to be discarded. - * @return The reduced total number of bytes written after the samples have been discarded, or 0 - * if the queue is now empty. - */ - public long discardUpstreamSamples(int discardFromIndex) { - int discardCount = getWriteIndex() - discardFromIndex; - Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); - length -= discardCount; - largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); - isLastSampleQueued = discardCount == 0 && isLastSampleQueued; - if (length == 0) { - return 0; - } else { - int relativeLastWriteIndex = getRelativeIndex(length - 1); - return offsets[relativeLastWriteIndex] + sizes[relativeLastWriteIndex]; - } - } - - public void sourceId(int sourceId) { - upstreamSourceId = sourceId; - } - - // Called by the consuming thread. - - /** - * Throws an error that's preventing data from being read. Does nothing if no such error exists. - * - * @throws IOException The underlying error. - */ - public void maybeThrowError() throws IOException { - // TODO: Avoid throwing if the DRM error is not preventing a read operation. - if (currentDrmSession != null && currentDrmSession.getState() == DrmSession.STATE_ERROR) { - throw Assertions.checkNotNull(currentDrmSession.getError()); - } - } - - /** Releases any owned {@link DrmSession} references. */ - public void releaseDrmSessionReferences() { - if (currentDrmSession != null) { - currentDrmSession.release(); - currentDrmSession = null; - // Clear downstream format to avoid violating the assumption that downstreamFormat.drmInitData - // != null implies currentSession != null - downstreamFormat = null; - } - } - - /** Returns the current absolute start index. */ - public int getFirstIndex() { - return absoluteFirstIndex; - } - - /** - * Returns the current absolute read index. - */ - public int getReadIndex() { - return absoluteFirstIndex + readPosition; - } - - /** - * Peeks the source id of the next sample to be read, or the current upstream source id if the - * queue is empty or if the read position is at the end of the queue. - * - * @return The source id. - */ - public synchronized int peekSourceId() { - int relativeReadIndex = getRelativeIndex(readPosition); - return hasNextSample() ? sourceIds[relativeReadIndex] : upstreamSourceId; - } - - /** - * Returns the upstream {@link Format} in which samples are being queued. - */ - public synchronized Format getUpstreamFormat() { - return upstreamFormatRequired ? null : upstreamFormat; - } - - /** - * Returns the largest sample timestamp that has been queued since the last call to - * {@link #reset(boolean)}. - *

    - * Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not - * considered as having been queued. Samples that were dequeued from the front of the queue are - * considered as having been queued. - * - * @return The largest sample timestamp that has been queued, or {@link Long#MIN_VALUE} if no - * samples have been queued. - */ - public synchronized long getLargestQueuedTimestampUs() { - return largestQueuedTimestampUs; - } - - /** - * Returns whether the last sample of the stream has knowingly been queued. A return value of - * {@code false} means that the last sample had not been queued or that it's unknown whether the - * last sample has been queued. - * - *

    Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not - * considered as having been queued. Samples that were dequeued from the front of the queue are - * considered as having been queued. - */ - public synchronized boolean isLastSampleQueued() { - return isLastSampleQueued; - } - - /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ - public synchronized long getFirstTimestampUs() { - return length == 0 ? Long.MIN_VALUE : timesUs[relativeFirstIndex]; - } - - /** - * Rewinds the read position to the first sample retained in the queue. - */ - public synchronized void rewind() { - readPosition = 0; - } - - /** - * Returns whether there is data available for reading. - * - *

    Note: If the stream has ended then a buffer with the end of stream flag can always be read - * from {@link #read}. Hence an ended stream is always ready. - * - * @param loadingFinished Whether no more samples will be written to the sample queue. When true, - * this method returns true if the sample queue is empty, because an empty sample queue means - * the end of stream has been reached. When false, this method returns false if the sample - * queue is empty. - */ - public boolean isReady(boolean loadingFinished) { - if (!hasNextSample()) { - return loadingFinished - || isLastSampleQueued - || (upstreamFormat != null && upstreamFormat != downstreamFormat); - } - int relativeReadIndex = getRelativeIndex(readPosition); - if (formats[relativeReadIndex] != downstreamFormat) { - // A format can be read. - return true; - } - return mayReadSample(relativeReadIndex); - } - - /** - * Attempts to read from the queue. - * - * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. - * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the - * end of the stream. If a sample is read then the buffer is populated with information about - * the sample, but not its data. The size and absolute position of the data in the rolling - * buffer is stored in {@code extrasHolder}, along with an encryption id if present and the - * absolute position of the first byte that may still be required after the current sample has - * been read. If a {@link DecoderInputBuffer#isFlagsOnly() flags-only} buffer is passed, only - * the buffer flags may be populated by this method and the read position of the queue will - * not change. May be null if the caller requires that the format of the stream be read even - * if it's not changing. - * @param formatRequired Whether the caller requires that the format of the stream be read even if - * it's not changing. A sample will never be read if set to true, however it is still possible - * for the end of stream or nothing to be read. - * @param loadingFinished True if an empty queue should be considered the end of the stream. - * @param extrasHolder The holder into which extra sample information should be written. - * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or - * {@link C#RESULT_BUFFER_READ}. - */ - @SuppressWarnings("ReferenceEquality") - public synchronized int read( - FormatHolder formatHolder, - DecoderInputBuffer buffer, - boolean formatRequired, - boolean loadingFinished, - SampleExtrasHolder extrasHolder) { - if (!hasNextSample()) { - if (loadingFinished || isLastSampleQueued) { - buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); - return C.RESULT_BUFFER_READ; - } else if (upstreamFormat != null && (formatRequired || upstreamFormat != downstreamFormat)) { - onFormatResult(Assertions.checkNotNull(upstreamFormat), formatHolder); - return C.RESULT_FORMAT_READ; - } else { - return C.RESULT_NOTHING_READ; - } - } - - int relativeReadIndex = getRelativeIndex(readPosition); - if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { - onFormatResult(formats[relativeReadIndex], formatHolder); - return C.RESULT_FORMAT_READ; - } - - if (!mayReadSample(relativeReadIndex)) { - return C.RESULT_NOTHING_READ; - } - - buffer.setFlags(flags[relativeReadIndex]); - buffer.timeUs = timesUs[relativeReadIndex]; - if (buffer.isFlagsOnly()) { - return C.RESULT_BUFFER_READ; - } - - extrasHolder.size = sizes[relativeReadIndex]; - extrasHolder.offset = offsets[relativeReadIndex]; - extrasHolder.cryptoData = cryptoDatas[relativeReadIndex]; - - readPosition++; - return C.RESULT_BUFFER_READ; - } - - /** - * Attempts to advance the read position to the sample before or at the specified time. - * - * @param timeUs The time to advance to. - * @param toKeyframe If true then attempts to advance to the keyframe before or at the specified - * time, rather than to any sample before or at that time. - * @param allowTimeBeyondBuffer Whether the operation can succeed if {@code timeUs} is beyond the - * end of the queue, by advancing the read position to the last sample (or keyframe) in the - * queue. - * @return The number of samples that were skipped if the operation was successful, which may be - * equal to 0, or {@link SampleQueue#ADVANCE_FAILED} if the operation was not successful. A - * successful advance is one in which the read position was unchanged or advanced, and is now - * at a sample meeting the specified criteria. - */ - public synchronized int advanceTo(long timeUs, boolean toKeyframe, - boolean allowTimeBeyondBuffer) { - int relativeReadIndex = getRelativeIndex(readPosition); - if (!hasNextSample() || timeUs < timesUs[relativeReadIndex] - || (timeUs > largestQueuedTimestampUs && !allowTimeBeyondBuffer)) { - return SampleQueue.ADVANCE_FAILED; - } - int offset = findSampleBefore(relativeReadIndex, length - readPosition, timeUs, toKeyframe); - if (offset == -1) { - return SampleQueue.ADVANCE_FAILED; - } - readPosition += offset; - return offset; - } - - /** - * Advances the read position to the end of the queue. - * - * @return The number of samples that were skipped. - */ - public synchronized int advanceToEnd() { - int skipCount = length - readPosition; - readPosition = length; - return skipCount; - } - - /** - * Attempts to set the read position to the specified sample index. - * - * @param sampleIndex The sample index. - * @return Whether the read position was set successfully. False is returned if the specified - * index is smaller than the index of the first sample in the queue, or larger than the index - * of the next sample that will be written. - */ - public synchronized boolean setReadPosition(int sampleIndex) { - if (absoluteFirstIndex <= sampleIndex && sampleIndex <= absoluteFirstIndex + length) { - readPosition = sampleIndex - absoluteFirstIndex; - return true; - } - return false; - } - - /** - * Discards up to but not including the sample immediately before or at the specified time. - * - * @param timeUs The time to discard up to. - * @param toKeyframe If true then discards samples up to the keyframe before or at the specified - * time, rather than just any sample before or at that time. - * @param stopAtReadPosition If true then samples are only discarded if they're before the read - * position. If false then samples at and beyond the read position may be discarded, in which - * case the read position is advanced to the first remaining sample. - * @return The corresponding offset up to which data should be discarded, or - * {@link C#POSITION_UNSET} if no discarding of data is necessary. - */ - public synchronized long discardTo(long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { - if (length == 0 || timeUs < timesUs[relativeFirstIndex]) { - return C.POSITION_UNSET; - } - int searchLength = stopAtReadPosition && readPosition != length ? readPosition + 1 : length; - int discardCount = findSampleBefore(relativeFirstIndex, searchLength, timeUs, toKeyframe); - if (discardCount == -1) { - return C.POSITION_UNSET; - } - return discardSamples(discardCount); - } - - /** - * Discards samples up to but not including the read position. - * - * @return The corresponding offset up to which data should be discarded, or - * {@link C#POSITION_UNSET} if no discarding of data is necessary. - */ - public synchronized long discardToRead() { - if (readPosition == 0) { - return C.POSITION_UNSET; - } - return discardSamples(readPosition); - } - - /** - * Discards all samples in the queue. The read position is also advanced. - * - * @return The corresponding offset up to which data should be discarded, or - * {@link C#POSITION_UNSET} if no discarding of data is necessary. - */ - public synchronized long discardToEnd() { - if (length == 0) { - return C.POSITION_UNSET; - } - return discardSamples(length); - } - - // Called by the loading thread. - - public synchronized boolean format(Format format) { - if (format == null) { - upstreamFormatRequired = true; - return false; - } - upstreamFormatRequired = false; - if (Util.areEqual(format, upstreamFormat)) { - // The format is unchanged. If format and upstreamFormat are different objects, we keep the - // current upstreamFormat so we can detect format changes in read() using cheap referential - // equality. - return false; - } else if (Util.areEqual(format, upstreamCommittedFormat)) { - // The format has changed back to the format of the last committed sample. If they are - // different objects, we revert back to using upstreamCommittedFormat as the upstreamFormat so - // we can detect format changes in read() using cheap referential equality. - upstreamFormat = upstreamCommittedFormat; - return true; - } else { - upstreamFormat = format; - return true; - } - } - - public synchronized void commitSample(long timeUs, @C.BufferFlags int sampleFlags, long offset, - int size, CryptoData cryptoData) { - if (upstreamKeyframeRequired) { - if ((sampleFlags & C.BUFFER_FLAG_KEY_FRAME) == 0) { - return; - } - upstreamKeyframeRequired = false; - } - Assertions.checkState(!upstreamFormatRequired); - - isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; - largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); - - int relativeEndIndex = getRelativeIndex(length); - timesUs[relativeEndIndex] = timeUs; - offsets[relativeEndIndex] = offset; - sizes[relativeEndIndex] = size; - flags[relativeEndIndex] = sampleFlags; - cryptoDatas[relativeEndIndex] = cryptoData; - formats[relativeEndIndex] = upstreamFormat; - sourceIds[relativeEndIndex] = upstreamSourceId; - upstreamCommittedFormat = upstreamFormat; - - length++; - if (length == capacity) { - // Increase the capacity. - int newCapacity = capacity + SAMPLE_CAPACITY_INCREMENT; - int[] newSourceIds = new int[newCapacity]; - long[] newOffsets = new long[newCapacity]; - long[] newTimesUs = new long[newCapacity]; - int[] newFlags = new int[newCapacity]; - int[] newSizes = new int[newCapacity]; - CryptoData[] newCryptoDatas = new CryptoData[newCapacity]; - Format[] newFormats = new Format[newCapacity]; - int beforeWrap = capacity - relativeFirstIndex; - System.arraycopy(offsets, relativeFirstIndex, newOffsets, 0, beforeWrap); - System.arraycopy(timesUs, relativeFirstIndex, newTimesUs, 0, beforeWrap); - System.arraycopy(flags, relativeFirstIndex, newFlags, 0, beforeWrap); - System.arraycopy(sizes, relativeFirstIndex, newSizes, 0, beforeWrap); - System.arraycopy(cryptoDatas, relativeFirstIndex, newCryptoDatas, 0, beforeWrap); - System.arraycopy(formats, relativeFirstIndex, newFormats, 0, beforeWrap); - System.arraycopy(sourceIds, relativeFirstIndex, newSourceIds, 0, beforeWrap); - int afterWrap = relativeFirstIndex; - System.arraycopy(offsets, 0, newOffsets, beforeWrap, afterWrap); - System.arraycopy(timesUs, 0, newTimesUs, beforeWrap, afterWrap); - System.arraycopy(flags, 0, newFlags, beforeWrap, afterWrap); - System.arraycopy(sizes, 0, newSizes, beforeWrap, afterWrap); - System.arraycopy(cryptoDatas, 0, newCryptoDatas, beforeWrap, afterWrap); - System.arraycopy(formats, 0, newFormats, beforeWrap, afterWrap); - System.arraycopy(sourceIds, 0, newSourceIds, beforeWrap, afterWrap); - offsets = newOffsets; - timesUs = newTimesUs; - flags = newFlags; - sizes = newSizes; - cryptoDatas = newCryptoDatas; - formats = newFormats; - sourceIds = newSourceIds; - relativeFirstIndex = 0; - length = capacity; - capacity = newCapacity; - } - } - - /** - * Attempts to discard samples from the end of the queue to allow samples starting from the - * specified timestamp to be spliced in. Samples will not be discarded prior to the read position. - * - * @param timeUs The timestamp at which the splice occurs. - * @return Whether the splice was successful. - */ - public synchronized boolean attemptSplice(long timeUs) { - if (length == 0) { - return timeUs > largestDiscardedTimestampUs; - } - long largestReadTimestampUs = Math.max(largestDiscardedTimestampUs, - getLargestTimestamp(readPosition)); - if (largestReadTimestampUs >= timeUs) { - return false; - } - int retainCount = length; - int relativeSampleIndex = getRelativeIndex(length - 1); - while (retainCount > readPosition && timesUs[relativeSampleIndex] >= timeUs) { - retainCount--; - relativeSampleIndex--; - if (relativeSampleIndex == -1) { - relativeSampleIndex = capacity - 1; - } - } - discardUpstreamSamples(absoluteFirstIndex + retainCount); - return true; - } - - // Internal methods. - - private boolean hasNextSample() { - return readPosition != length; - } - - /** - * Sets the downstream format, performs DRM resource management, and populates the {@code - * outputFormatHolder}. - * - * @param newFormat The new downstream format. - * @param outputFormatHolder The output {@link FormatHolder}. - */ - private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { - outputFormatHolder.format = newFormat; - boolean isFirstFormat = downstreamFormat == null; - DrmInitData oldDrmInitData = isFirstFormat ? null : downstreamFormat.drmInitData; - downstreamFormat = newFormat; - if (drmSessionManager == DrmSessionManager.DUMMY) { - // Avoid attempting to acquire a session using the dummy DRM session manager. It's likely that - // the media source creation has not yet been migrated and the renderer can acquire the - // session for the read DRM init data. - // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. - return; - } - DrmInitData newDrmInitData = newFormat.drmInitData; - outputFormatHolder.includesDrmSession = true; - outputFormatHolder.drmSession = currentDrmSession; - if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { - // Nothing to do. - return; - } - // Ensure we acquire the new session before releasing the previous one in case the same session - // is being used for both DrmInitData. - DrmSession previousSession = currentDrmSession; - Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); - currentDrmSession = - newDrmInitData != null - ? drmSessionManager.acquireSession(playbackLooper, newDrmInitData) - : drmSessionManager.acquirePlaceholderSession( - playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); - outputFormatHolder.drmSession = currentDrmSession; - - if (previousSession != null) { - previousSession.release(); - } - } - - /** - * Returns whether it's possible to read the next sample. - * - * @param relativeReadIndex The relative read index of the next sample. - * @return Whether it's possible to read the next sample. - */ - private boolean mayReadSample(int relativeReadIndex) { - if (drmSessionManager == DrmSessionManager.DUMMY) { - // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. - // For protected content it's likely that the DrmSessionManager is still being injected into - // the renderers. We assume that the renderers will be able to acquire a DrmSession if needed. - return true; - } - return currentDrmSession == null - || currentDrmSession.getState() == DrmSession.STATE_OPENED_WITH_KEYS - || ((flags[relativeReadIndex] & C.BUFFER_FLAG_ENCRYPTED) == 0 - && currentDrmSession.playClearSamplesWithoutKeys()); - } - - /** - * Finds the sample in the specified range that's before or at the specified time. If {@code - * keyframe} is {@code true} then the sample is additionally required to be a keyframe. - * - * @param relativeStartIndex The relative index from which to start searching. - * @param length The length of the range being searched. - * @param timeUs The specified time. - * @param keyframe Whether only keyframes should be considered. - * @return The offset from {@code relativeFirstIndex} to the found sample, or -1 if no matching - * sample was found. - */ - private int findSampleBefore(int relativeStartIndex, int length, long timeUs, boolean keyframe) { - // This could be optimized to use a binary search, however in practice callers to this method - // normally pass times near to the start of the search region. Hence it's unclear whether - // switching to a binary search would yield any real benefit. - int sampleCountToTarget = -1; - int searchIndex = relativeStartIndex; - for (int i = 0; i < length && timesUs[searchIndex] <= timeUs; i++) { - if (!keyframe || (flags[searchIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { - // We've found a suitable sample. - sampleCountToTarget = i; - } - searchIndex++; - if (searchIndex == capacity) { - searchIndex = 0; - } - } - return sampleCountToTarget; - } - - /** - * Discards the specified number of samples. - * - * @param discardCount The number of samples to discard. - * @return The corresponding offset up to which data should be discarded. - */ - private long discardSamples(int discardCount) { - largestDiscardedTimestampUs = Math.max(largestDiscardedTimestampUs, - getLargestTimestamp(discardCount)); - length -= discardCount; - absoluteFirstIndex += discardCount; - relativeFirstIndex += discardCount; - if (relativeFirstIndex >= capacity) { - relativeFirstIndex -= capacity; - } - readPosition -= discardCount; - if (readPosition < 0) { - readPosition = 0; - } - if (length == 0) { - int relativeLastDiscardIndex = (relativeFirstIndex == 0 ? capacity : relativeFirstIndex) - 1; - return offsets[relativeLastDiscardIndex] + sizes[relativeLastDiscardIndex]; - } else { - return offsets[relativeFirstIndex]; - } - } - - /** - * Finds the largest timestamp of any sample from the start of the queue up to the specified - * length, assuming that the timestamps prior to a keyframe are always less than the timestamp of - * the keyframe itself, and of subsequent frames. - * - * @param length The length of the range being searched. - * @return The largest timestamp, or {@link Long#MIN_VALUE} if {@code length == 0}. - */ - private long getLargestTimestamp(int length) { - if (length == 0) { - return Long.MIN_VALUE; - } - long largestTimestampUs = Long.MIN_VALUE; - int relativeSampleIndex = getRelativeIndex(length - 1); - for (int i = 0; i < length; i++) { - largestTimestampUs = Math.max(largestTimestampUs, timesUs[relativeSampleIndex]); - if ((flags[relativeSampleIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { - break; - } - relativeSampleIndex--; - if (relativeSampleIndex == -1) { - relativeSampleIndex = capacity - 1; - } - } - return largestTimestampUs; - } - - /** - * Returns the relative index for a given offset from the start of the queue. - * - * @param offset The offset, which must be in the range [0, length]. - */ - private int getRelativeIndex(int offset) { - int relativeIndex = relativeFirstIndex + offset; - return relativeIndex < capacity ? relativeIndex : relativeIndex - capacity; - } - -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index 1230b45fe4..8eb3bfcb0a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 The Android Open Source Project + * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,29 +15,28 @@ */ package com.google.android.exoplayer2.source; +import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.TrackOutput; -import com.google.android.exoplayer2.source.SampleMetadataQueue.SampleExtrasHolder; -import com.google.android.exoplayer2.upstream.Allocation; import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; -import java.io.EOFException; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; -import java.nio.ByteBuffer; /** A queue of media samples. */ public class SampleQueue implements TrackOutput { - /** - * A listener for changes to the upstream format. - */ + /** A listener for changes to the upstream format. */ public interface UpstreamFormatChangedListener { /** @@ -46,31 +45,47 @@ public class SampleQueue implements TrackOutput { * @param format The new upstream format. */ void onUpstreamFormatChanged(Format format); - } public static final int ADVANCE_FAILED = -1; - private static final int INITIAL_SCRATCH_SIZE = 32; + private static final int SAMPLE_CAPACITY_INCREMENT = 1000; - private final Allocator allocator; - private final int allocationLength; - private final SampleMetadataQueue metadataQueue; + private final SampleDataQueue sampleDataQueue; private final SampleExtrasHolder extrasHolder; - private final ParsableByteArray scratch; + private final DrmSessionManager drmSessionManager; + private UpstreamFormatChangedListener upstreamFormatChangeListener; - // References into the linked list of allocations. - private AllocationNode firstAllocationNode; - private AllocationNode readAllocationNode; - private AllocationNode writeAllocationNode; + @Nullable private Format downstreamFormat; + @Nullable private DrmSession currentDrmSession; + + private int capacity; + private int[] sourceIds; + private long[] offsets; + private int[] sizes; + private int[] flags; + private long[] timesUs; + private CryptoData[] cryptoDatas; + private Format[] formats; + + private int length; + private int absoluteFirstIndex; + private int relativeFirstIndex; + private int readPosition; + + private long largestDiscardedTimestampUs; + private long largestQueuedTimestampUs; + private boolean isLastSampleQueued; + private boolean upstreamKeyframeRequired; + private boolean upstreamFormatRequired; + private Format upstreamFormat; + private Format upstreamCommittedFormat; + private int upstreamSourceId; - // Accessed only by the loading thread (or the consuming thread when there is no loading thread). private boolean pendingFormatAdjustment; private Format lastUnadjustedFormat; private long sampleOffsetUs; - private long totalBytesWritten; private boolean pendingSplice; - private UpstreamFormatChangedListener upstreamFormatChangeListener; /** * Creates a sample queue. @@ -80,27 +95,40 @@ public class SampleQueue implements TrackOutput { * from. The created instance does not take ownership of this {@link DrmSessionManager}. */ public SampleQueue(Allocator allocator, DrmSessionManager drmSessionManager) { - this.allocator = allocator; - allocationLength = allocator.getIndividualAllocationLength(); - metadataQueue = new SampleMetadataQueue(drmSessionManager); + sampleDataQueue = new SampleDataQueue(allocator); + this.drmSessionManager = drmSessionManager; extrasHolder = new SampleExtrasHolder(); - scratch = new ParsableByteArray(INITIAL_SCRATCH_SIZE); - firstAllocationNode = new AllocationNode(0, allocationLength); - readAllocationNode = firstAllocationNode; - writeAllocationNode = firstAllocationNode; + capacity = SAMPLE_CAPACITY_INCREMENT; + sourceIds = new int[capacity]; + offsets = new long[capacity]; + timesUs = new long[capacity]; + flags = new int[capacity]; + sizes = new int[capacity]; + cryptoDatas = new CryptoData[capacity]; + formats = new Format[capacity]; + largestDiscardedTimestampUs = Long.MIN_VALUE; + largestQueuedTimestampUs = Long.MIN_VALUE; + upstreamFormatRequired = true; + upstreamKeyframeRequired = true; } - // Called by the consuming thread, but only when there is no loading thread. + // Called by the consuming thread when there is no loading thread. /** - * Resets the output without clearing the upstream format. Equivalent to {@code reset(false)}. + * Calls {@link #reset(boolean) reset(true)} and releases any owned {@link DrmSession} references. */ + public void release() { + reset(/* resetUpstreamFormat= */ true); + releaseDrmSessionReferences(); + } + + /** Resets the output without clearing the upstream format. Equivalent to {@code reset(false)}. */ public void reset() { - reset(false); + reset(/* resetUpstreamFormat= */ false); } /** - * Resets the output. + * Clears all samples from the queue. * * @param resetUpstreamFormat Whether the upstream format should be cleared. If set to false, * samples queued after the reset (and before a subsequent call to {@link #format(Format)}) @@ -108,13 +136,20 @@ public class SampleQueue implements TrackOutput { * must be called after the reset before any more samples can be queued. */ public void reset(boolean resetUpstreamFormat) { - metadataQueue.reset(resetUpstreamFormat); - clearAllocationNodes(firstAllocationNode); - firstAllocationNode = new AllocationNode(0, allocationLength); - readAllocationNode = firstAllocationNode; - writeAllocationNode = firstAllocationNode; - totalBytesWritten = 0; - allocator.trim(); + sampleDataQueue.reset(); + length = 0; + absoluteFirstIndex = 0; + relativeFirstIndex = 0; + readPosition = 0; + upstreamKeyframeRequired = true; + largestDiscardedTimestampUs = Long.MIN_VALUE; + largestQueuedTimestampUs = Long.MIN_VALUE; + isLastSampleQueued = false; + upstreamCommittedFormat = null; + if (resetUpstreamFormat) { + upstreamFormat = null; + upstreamFormatRequired = true; + } } /** @@ -123,21 +158,17 @@ public class SampleQueue implements TrackOutput { * @param sourceId The source identifier. */ public void sourceId(int sourceId) { - metadataQueue.sourceId(sourceId); + upstreamSourceId = sourceId; } - /** - * Indicates samples that are subsequently queued should be spliced into those already queued. - */ + /** Indicates samples that are subsequently queued should be spliced into those already queued. */ public void splice() { pendingSplice = true; } - /** - * Returns the current absolute write index. - */ + /** Returns the current absolute write index. */ public int getWriteIndex() { - return metadataQueue.getWriteIndex(); + return absoluteFirstIndex + length; } /** @@ -147,55 +178,37 @@ public class SampleQueue implements TrackOutput { * range [{@link #getReadIndex()}, {@link #getWriteIndex()}]. */ public void discardUpstreamSamples(int discardFromIndex) { - totalBytesWritten = metadataQueue.discardUpstreamSamples(discardFromIndex); - if (totalBytesWritten == 0 || totalBytesWritten == firstAllocationNode.startPosition) { - clearAllocationNodes(firstAllocationNode); - firstAllocationNode = new AllocationNode(totalBytesWritten, allocationLength); - readAllocationNode = firstAllocationNode; - writeAllocationNode = firstAllocationNode; - } else { - // Find the last node containing at least 1 byte of data that we need to keep. - AllocationNode lastNodeToKeep = firstAllocationNode; - while (totalBytesWritten > lastNodeToKeep.endPosition) { - lastNodeToKeep = lastNodeToKeep.next; - } - // Discard all subsequent nodes. - AllocationNode firstNodeToDiscard = lastNodeToKeep.next; - clearAllocationNodes(firstNodeToDiscard); - // Reset the successor of the last node to be an uninitialized node. - lastNodeToKeep.next = new AllocationNode(lastNodeToKeep.endPosition, allocationLength); - // Update writeAllocationNode and readAllocationNode as necessary. - writeAllocationNode = totalBytesWritten == lastNodeToKeep.endPosition ? lastNodeToKeep.next - : lastNodeToKeep; - if (readAllocationNode == firstNodeToDiscard) { - readAllocationNode = lastNodeToKeep.next; - } - } + sampleDataQueue.discardUpstreamSampleBytes(discardUpstreamSampleMetadata(discardFromIndex)); } // Called by the consuming thread. + /** Calls {@link #discardToEnd()} and releases any owned {@link DrmSession} references. */ + public void preRelease() { + discardToEnd(); + releaseDrmSessionReferences(); + } + /** * Throws an error that's preventing data from being read. Does nothing if no such error exists. * * @throws IOException The underlying error. */ public void maybeThrowError() throws IOException { - metadataQueue.maybeThrowError(); + // TODO: Avoid throwing if the DRM error is not preventing a read operation. + if (currentDrmSession != null && currentDrmSession.getState() == DrmSession.STATE_ERROR) { + throw Assertions.checkNotNull(currentDrmSession.getError()); + } } - /** - * Returns the absolute index of the first sample. - */ + /** Returns the current absolute start index. */ public int getFirstIndex() { - return metadataQueue.getFirstIndex(); + return absoluteFirstIndex; } - /** - * Returns the current absolute read index. - */ + /** Returns the current absolute read index. */ public int getReadIndex() { - return metadataQueue.getReadIndex(); + return absoluteFirstIndex + readPosition; } /** @@ -204,129 +217,71 @@ public class SampleQueue implements TrackOutput { * * @return The source id. */ - public int peekSourceId() { - return metadataQueue.peekSourceId(); + public synchronized int peekSourceId() { + int relativeReadIndex = getRelativeIndex(readPosition); + return hasNextSample() ? sourceIds[relativeReadIndex] : upstreamSourceId; } - /** - * Returns the upstream {@link Format} in which samples are being queued. - */ - public Format getUpstreamFormat() { - return metadataQueue.getUpstreamFormat(); + /** Returns the upstream {@link Format} in which samples are being queued. */ + public synchronized Format getUpstreamFormat() { + return upstreamFormatRequired ? null : upstreamFormat; } /** * Returns the largest sample timestamp that has been queued since the last {@link #reset}. - *

    - * Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * + *

    Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not * considered as having been queued. Samples that were dequeued from the front of the queue are * considered as having been queued. * * @return The largest sample timestamp that has been queued, or {@link Long#MIN_VALUE} if no * samples have been queued. */ - public long getLargestQueuedTimestampUs() { - return metadataQueue.getLargestQueuedTimestampUs(); + public synchronized long getLargestQueuedTimestampUs() { + return largestQueuedTimestampUs; } /** * Returns whether the last sample of the stream has knowingly been queued. A return value of * {@code false} means that the last sample had not been queued or that it's unknown whether the * last sample has been queued. + * + *

    Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. */ - public boolean isLastSampleQueued() { - return metadataQueue.isLastSampleQueued(); + public synchronized boolean isLastSampleQueued() { + return isLastSampleQueued; } /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ - public long getFirstTimestampUs() { - return metadataQueue.getFirstTimestampUs(); + public synchronized long getFirstTimestampUs() { + return length == 0 ? Long.MIN_VALUE : timesUs[relativeFirstIndex]; } /** - * Rewinds the read position to the first sample in the queue. - */ - public void rewind() { - metadataQueue.rewind(); - readAllocationNode = firstAllocationNode; - } - - /** - * Discards up to but not including the sample immediately before or at the specified time. + * Returns whether there is data available for reading. * - * @param timeUs The time to discard to. - * @param toKeyframe If true then discards samples up to the keyframe before or at the specified - * time, rather than any sample before or at that time. - * @param stopAtReadPosition If true then samples are only discarded if they're before the - * read position. If false then samples at and beyond the read position may be discarded, in - * which case the read position is advanced to the first remaining sample. - */ - public void discardTo(long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { - discardDownstreamTo(metadataQueue.discardTo(timeUs, toKeyframe, stopAtReadPosition)); - } - - /** - * Discards up to but not including the read position. - */ - public void discardToRead() { - discardDownstreamTo(metadataQueue.discardToRead()); - } - - /** Calls {@link #discardToEnd()} and releases any owned {@link DrmSession} references. */ - public void preRelease() { - discardToEnd(); - metadataQueue.releaseDrmSessionReferences(); - } - - /** Calls {@link #reset()} and releases any owned {@link DrmSession} references. */ - public void release() { - reset(); - metadataQueue.releaseDrmSessionReferences(); - } - - /** - * Discards to the end of the queue. The read position is also advanced. - */ - public void discardToEnd() { - discardDownstreamTo(metadataQueue.discardToEnd()); - } - - /** - * Advances the read position to the end of the queue. + *

    Note: If the stream has ended then a buffer with the end of stream flag can always be read + * from {@link #read}. Hence an ended stream is always ready. * - * @return The number of samples that were skipped. + * @param loadingFinished Whether no more samples will be written to the sample queue. When true, + * this method returns true if the sample queue is empty, because an empty sample queue means + * the end of stream has been reached. When false, this method returns false if the sample + * queue is empty. */ - public int advanceToEnd() { - return metadataQueue.advanceToEnd(); - } - - /** - * Attempts to advance the read position to the sample before or at the specified time. - * - * @param timeUs The time to advance to. - * @param toKeyframe If true then attempts to advance to the keyframe before or at the specified - * time, rather than to any sample before or at that time. - * @param allowTimeBeyondBuffer Whether the operation can succeed if {@code timeUs} is beyond the - * end of the queue, by advancing the read position to the last sample (or keyframe). - * @return The number of samples that were skipped if the operation was successful, which may be - * equal to 0, or {@link #ADVANCE_FAILED} if the operation was not successful. A successful - * advance is one in which the read position was unchanged or advanced, and is now at a sample - * meeting the specified criteria. - */ - public int advanceTo(long timeUs, boolean toKeyframe, boolean allowTimeBeyondBuffer) { - return metadataQueue.advanceTo(timeUs, toKeyframe, allowTimeBeyondBuffer); - } - - /** - * Attempts to set the read position to the specified sample index. - * - * @param sampleIndex The sample index. - * @return Whether the read position was set successfully. False is returned if the specified - * index is smaller than the index of the first sample in the queue, or larger than the index - * of the next sample that will be written. - */ - public boolean setReadPosition(int sampleIndex) { - return metadataQueue.setReadPosition(sampleIndex); + public synchronized boolean isReady(boolean loadingFinished) { + if (!hasNextSample()) { + return loadingFinished + || isLastSampleQueued + || (upstreamFormat != null && upstreamFormat != downstreamFormat); + } + int relativeReadIndex = getRelativeIndex(readPosition); + if (formats[relativeReadIndex] != downstreamFormat) { + // A format can be read. + return true; + } + return mayReadSample(relativeReadIndex); } /** @@ -364,238 +319,106 @@ public class SampleQueue implements TrackOutput { boolean loadingFinished, long decodeOnlyUntilUs) { int result = - metadataQueue.read(formatHolder, buffer, formatRequired, loadingFinished, extrasHolder); - if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream()) { - if (buffer.timeUs < decodeOnlyUntilUs) { - buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); - } - if (!buffer.isFlagsOnly()) { - readToBuffer(buffer, extrasHolder); - } + readSampleMetadata( + formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilUs, extrasHolder); + if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream() && !buffer.isFlagsOnly()) { + sampleDataQueue.readToBuffer(buffer, extrasHolder); } return result; } - /** - * Returns whether there is data available for reading. - * - *

    Note: If the stream has ended then a buffer with the end of stream flag can always be read - * from {@link #read}. Hence an ended stream is always ready. - * - * @param loadingFinished Whether no more samples will be written to the sample queue. When true, - * this method returns true if the sample queue is empty, because an empty sample queue means - * the end of stream has been reached. When false, this method returns false if the sample - * queue is empty. - */ - public boolean isReady(boolean loadingFinished) { - return metadataQueue.isReady(loadingFinished); + /** Rewinds the read position to the first sample in the queue. */ + public void rewind() { + rewindMetadata(); + sampleDataQueue.rewind(); } /** - * Reads data from the rolling buffer to populate a decoder input buffer. + * Attempts to advance the read position to the sample before or at the specified time. * - * @param buffer The buffer to populate. - * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + * @param timeUs The time to advance to. + * @param toKeyframe If true then attempts to advance to the keyframe before or at the specified + * time, rather than to any sample before or at that time. + * @param allowTimeBeyondBuffer Whether the operation can succeed if {@code timeUs} is beyond the + * end of the queue, by advancing the read position to the last sample (or keyframe). + * @return The number of samples that were skipped if the operation was successful, which may be + * equal to 0, or {@link #ADVANCE_FAILED} if the operation was not successful. A successful + * advance is one in which the read position was unchanged or advanced, and is now at a sample + * meeting the specified criteria. */ - private void readToBuffer(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { - // Read encryption data if the sample is encrypted. - if (buffer.isEncrypted()) { - readEncryptionData(buffer, extrasHolder); + public synchronized int advanceTo( + long timeUs, boolean toKeyframe, boolean allowTimeBeyondBuffer) { + int relativeReadIndex = getRelativeIndex(readPosition); + if (!hasNextSample() + || timeUs < timesUs[relativeReadIndex] + || (timeUs > largestQueuedTimestampUs && !allowTimeBeyondBuffer)) { + return SampleQueue.ADVANCE_FAILED; } - // Read sample data, extracting supplemental data into a separate buffer if needed. - if (buffer.hasSupplementalData()) { - // If there is supplemental data, the sample data is prefixed by its size. - scratch.reset(4); - readData(extrasHolder.offset, scratch.data, 4); - int sampleSize = scratch.readUnsignedIntToInt(); - extrasHolder.offset += 4; - extrasHolder.size -= 4; - - // Write the sample data. - buffer.ensureSpaceForWrite(sampleSize); - readData(extrasHolder.offset, buffer.data, sampleSize); - extrasHolder.offset += sampleSize; - extrasHolder.size -= sampleSize; - - // Write the remaining data as supplemental data. - buffer.resetSupplementalData(extrasHolder.size); - readData(extrasHolder.offset, buffer.supplementalData, extrasHolder.size); - } else { - // Write the sample data. - buffer.ensureSpaceForWrite(extrasHolder.size); - readData(extrasHolder.offset, buffer.data, extrasHolder.size); + int offset = findSampleBefore(relativeReadIndex, length - readPosition, timeUs, toKeyframe); + if (offset == -1) { + return SampleQueue.ADVANCE_FAILED; } + readPosition += offset; + return offset; } /** - * Reads encryption data for the current sample. + * Advances the read position to the end of the queue. * - *

    The encryption data is written into {@link DecoderInputBuffer#cryptoInfo}, and {@link - * SampleExtrasHolder#size} is adjusted to subtract the number of bytes that were read. The same - * value is added to {@link SampleExtrasHolder#offset}. - * - * @param buffer The buffer into which the encryption data should be written. - * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + * @return The number of samples that were skipped. */ - private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { - long offset = extrasHolder.offset; - - // Read the signal byte. - scratch.reset(1); - readData(offset, scratch.data, 1); - offset++; - byte signalByte = scratch.data[0]; - boolean subsampleEncryption = (signalByte & 0x80) != 0; - int ivSize = signalByte & 0x7F; - - // Read the initialization vector. - if (buffer.cryptoInfo.iv == null) { - buffer.cryptoInfo.iv = new byte[16]; - } - readData(offset, buffer.cryptoInfo.iv, ivSize); - offset += ivSize; - - // Read the subsample count, if present. - int subsampleCount; - if (subsampleEncryption) { - scratch.reset(2); - readData(offset, scratch.data, 2); - offset += 2; - subsampleCount = scratch.readUnsignedShort(); - } else { - subsampleCount = 1; - } - - // Write the clear and encrypted subsample sizes. - int[] clearDataSizes = buffer.cryptoInfo.numBytesOfClearData; - if (clearDataSizes == null || clearDataSizes.length < subsampleCount) { - clearDataSizes = new int[subsampleCount]; - } - int[] encryptedDataSizes = buffer.cryptoInfo.numBytesOfEncryptedData; - if (encryptedDataSizes == null || encryptedDataSizes.length < subsampleCount) { - encryptedDataSizes = new int[subsampleCount]; - } - if (subsampleEncryption) { - int subsampleDataLength = 6 * subsampleCount; - scratch.reset(subsampleDataLength); - readData(offset, scratch.data, subsampleDataLength); - offset += subsampleDataLength; - scratch.setPosition(0); - for (int i = 0; i < subsampleCount; i++) { - clearDataSizes[i] = scratch.readUnsignedShort(); - encryptedDataSizes[i] = scratch.readUnsignedIntToInt(); - } - } else { - clearDataSizes[0] = 0; - encryptedDataSizes[0] = extrasHolder.size - (int) (offset - extrasHolder.offset); - } - - // Populate the cryptoInfo. - CryptoData cryptoData = extrasHolder.cryptoData; - buffer.cryptoInfo.set(subsampleCount, clearDataSizes, encryptedDataSizes, - cryptoData.encryptionKey, buffer.cryptoInfo.iv, cryptoData.cryptoMode, - cryptoData.encryptedBlocks, cryptoData.clearBlocks); - - // Adjust the offset and size to take into account the bytes read. - int bytesRead = (int) (offset - extrasHolder.offset); - extrasHolder.offset += bytesRead; - extrasHolder.size -= bytesRead; + public synchronized int advanceToEnd() { + int skipCount = length - readPosition; + readPosition = length; + return skipCount; } /** - * Reads data from the front of the rolling buffer. + * Attempts to set the read position to the specified sample index. * - * @param absolutePosition The absolute position from which data should be read. - * @param target The buffer into which data should be written. - * @param length The number of bytes to read. + * @param sampleIndex The sample index. + * @return Whether the read position was set successfully. False is returned if the specified + * index is smaller than the index of the first sample in the queue, or larger than the index + * of the next sample that will be written. */ - private void readData(long absolutePosition, ByteBuffer target, int length) { - advanceReadTo(absolutePosition); - int remaining = length; - while (remaining > 0) { - int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); - Allocation allocation = readAllocationNode.allocation; - target.put(allocation.data, readAllocationNode.translateOffset(absolutePosition), toCopy); - remaining -= toCopy; - absolutePosition += toCopy; - if (absolutePosition == readAllocationNode.endPosition) { - readAllocationNode = readAllocationNode.next; - } + public synchronized boolean setReadPosition(int sampleIndex) { + if (absoluteFirstIndex <= sampleIndex && sampleIndex <= absoluteFirstIndex + length) { + readPosition = sampleIndex - absoluteFirstIndex; + return true; } + return false; } /** - * Reads data from the front of the rolling buffer. + * Discards up to but not including the sample immediately before or at the specified time. * - * @param absolutePosition The absolute position from which data should be read. - * @param target The array into which data should be written. - * @param length The number of bytes to read. + * @param timeUs The time to discard up to. + * @param toKeyframe If true then discards samples up to the keyframe before or at the specified + * time, rather than any sample before or at that time. + * @param stopAtReadPosition If true then samples are only discarded if they're before the read + * position. If false then samples at and beyond the read position may be discarded, in which + * case the read position is advanced to the first remaining sample. */ - private void readData(long absolutePosition, byte[] target, int length) { - advanceReadTo(absolutePosition); - int remaining = length; - while (remaining > 0) { - int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); - Allocation allocation = readAllocationNode.allocation; - System.arraycopy(allocation.data, readAllocationNode.translateOffset(absolutePosition), - target, length - remaining, toCopy); - remaining -= toCopy; - absolutePosition += toCopy; - if (absolutePosition == readAllocationNode.endPosition) { - readAllocationNode = readAllocationNode.next; - } - } + public void discardTo(long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { + sampleDataQueue.discardDownstreamTo( + discardSampleMetadataTo(timeUs, toKeyframe, stopAtReadPosition)); } - /** - * Advances {@link #readAllocationNode} to the specified absolute position. - * - * @param absolutePosition The position to which {@link #readAllocationNode} should be advanced. - */ - private void advanceReadTo(long absolutePosition) { - while (absolutePosition >= readAllocationNode.endPosition) { - readAllocationNode = readAllocationNode.next; - } + /** Discards up to but not including the read position. */ + public void discardToRead() { + sampleDataQueue.discardDownstreamTo(discardSampleMetadataToRead()); } - /** - * Advances {@link #firstAllocationNode} to the specified absolute position. - * {@link #readAllocationNode} is also advanced if necessary to avoid it falling behind - * {@link #firstAllocationNode}. Nodes that have been advanced past are cleared, and their - * underlying allocations are returned to the allocator. - * - * @param absolutePosition The position to which {@link #firstAllocationNode} should be advanced. - * May be {@link C#POSITION_UNSET}, in which case calling this method is a no-op. - */ - private void discardDownstreamTo(long absolutePosition) { - if (absolutePosition == C.POSITION_UNSET) { - return; - } - while (absolutePosition >= firstAllocationNode.endPosition) { - allocator.release(firstAllocationNode.allocation); - firstAllocationNode = firstAllocationNode.clear(); - } - // If we discarded the node referenced by readAllocationNode then we need to advance it to the - // first remaining node. - if (readAllocationNode.startPosition < firstAllocationNode.startPosition) { - readAllocationNode = firstAllocationNode; - } + /** Discards all samples in the queue and advances the read position. */ + public void discardToEnd() { + sampleDataQueue.discardDownstreamTo(discardSampleMetadataToEnd()); } // Called by the loading thread. /** - * Sets a listener to be notified of changes to the upstream format. - * - * @param listener The listener. - */ - public void setUpstreamFormatChangeListener(UpstreamFormatChangedListener listener) { - upstreamFormatChangeListener = listener; - } - - /** - * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples - * that are subsequently queued. + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that + * are subsequently queued. * * @param sampleOffsetUs The timestamp offset in microseconds. */ @@ -606,11 +429,22 @@ public class SampleQueue implements TrackOutput { } } + /** + * Sets a listener to be notified of changes to the upstream format. + * + * @param listener The listener. + */ + public void setUpstreamFormatChangeListener(UpstreamFormatChangedListener listener) { + upstreamFormatChangeListener = listener; + } + + // TrackOutput implementation. Called by the loading thread. + @Override - public void format(Format format) { - Format adjustedFormat = getAdjustedSampleFormat(format, sampleOffsetUs); - boolean formatChanged = metadataQueue.format(adjustedFormat); - lastUnadjustedFormat = format; + public void format(Format unadjustedFormat) { + Format adjustedFormat = getAdjustedSampleFormat(unadjustedFormat, sampleOffsetUs); + boolean formatChanged = setUpstreamFormat(adjustedFormat); + lastUnadjustedFormat = unadjustedFormat; pendingFormatAdjustment = false; if (upstreamFormatChangeListener != null && formatChanged) { upstreamFormatChangeListener.onUpstreamFormatChanged(adjustedFormat); @@ -620,28 +454,12 @@ public class SampleQueue implements TrackOutput { @Override public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) throws IOException, InterruptedException { - length = preAppend(length); - int bytesAppended = input.read(writeAllocationNode.allocation.data, - writeAllocationNode.translateOffset(totalBytesWritten), length); - if (bytesAppended == C.RESULT_END_OF_INPUT) { - if (allowEndOfInput) { - return C.RESULT_END_OF_INPUT; - } - throw new EOFException(); - } - postAppend(bytesAppended); - return bytesAppended; + return sampleDataQueue.sampleData(input, length, allowEndOfInput); } @Override public void sampleData(ParsableByteArray buffer, int length) { - while (length > 0) { - int bytesAppended = preAppend(length); - buffer.readBytes(writeAllocationNode.allocation.data, - writeAllocationNode.translateOffset(totalBytesWritten), bytesAppended); - length -= bytesAppended; - postAppend(bytesAppended); - } + sampleDataQueue.sampleData(buffer, length); } @Override @@ -656,66 +474,388 @@ public class SampleQueue implements TrackOutput { } timeUs += sampleOffsetUs; if (pendingSplice) { - if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0 || !metadataQueue.attemptSplice(timeUs)) { + if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0 || !attemptSplice(timeUs)) { return; } pendingSplice = false; } - long absoluteOffset = totalBytesWritten - size - offset; - metadataQueue.commitSample(timeUs, flags, absoluteOffset, size, cryptoData); + long absoluteOffset = sampleDataQueue.getTotalBytesWritten() - size - offset; + commitSample(timeUs, flags, absoluteOffset, size, cryptoData); } - // Private methods. + // Internal methods. + + private synchronized void rewindMetadata() { + readPosition = 0; + } + + private synchronized int readSampleMetadata( + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired, + boolean loadingFinished, + long decodeOnlyUntilUs, + SampleExtrasHolder extrasHolder) { + if (!hasNextSample()) { + if (loadingFinished || isLastSampleQueued) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } else if (upstreamFormat != null && (formatRequired || upstreamFormat != downstreamFormat)) { + onFormatResult(Assertions.checkNotNull(upstreamFormat), formatHolder); + return C.RESULT_FORMAT_READ; + } else { + return C.RESULT_NOTHING_READ; + } + } + + int relativeReadIndex = getRelativeIndex(readPosition); + if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { + onFormatResult(formats[relativeReadIndex], formatHolder); + return C.RESULT_FORMAT_READ; + } + + if (!mayReadSample(relativeReadIndex)) { + return C.RESULT_NOTHING_READ; + } + + buffer.setFlags(flags[relativeReadIndex]); + buffer.timeUs = timesUs[relativeReadIndex]; + if (buffer.timeUs < decodeOnlyUntilUs) { + buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); + } + if (buffer.isFlagsOnly()) { + return C.RESULT_BUFFER_READ; + } + extrasHolder.size = sizes[relativeReadIndex]; + extrasHolder.offset = offsets[relativeReadIndex]; + extrasHolder.cryptoData = cryptoDatas[relativeReadIndex]; + + readPosition++; + return C.RESULT_BUFFER_READ; + } + + private synchronized boolean setUpstreamFormat(Format format) { + if (format == null) { + upstreamFormatRequired = true; + return false; + } + upstreamFormatRequired = false; + if (Util.areEqual(format, upstreamFormat)) { + // The format is unchanged. If format and upstreamFormat are different objects, we keep the + // current upstreamFormat so we can detect format changes in read() using cheap referential + // equality. + return false; + } else if (Util.areEqual(format, upstreamCommittedFormat)) { + // The format has changed back to the format of the last committed sample. If they are + // different objects, we revert back to using upstreamCommittedFormat as the upstreamFormat + // so we can detect format changes in read() using cheap referential equality. + upstreamFormat = upstreamCommittedFormat; + return true; + } else { + upstreamFormat = format; + return true; + } + } + + private synchronized long discardSampleMetadataTo( + long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { + if (length == 0 || timeUs < timesUs[relativeFirstIndex]) { + return C.POSITION_UNSET; + } + int searchLength = stopAtReadPosition && readPosition != length ? readPosition + 1 : length; + int discardCount = findSampleBefore(relativeFirstIndex, searchLength, timeUs, toKeyframe); + if (discardCount == -1) { + return C.POSITION_UNSET; + } + return discardSamples(discardCount); + } + + public synchronized long discardSampleMetadataToRead() { + if (readPosition == 0) { + return C.POSITION_UNSET; + } + return discardSamples(readPosition); + } + + private synchronized long discardSampleMetadataToEnd() { + if (length == 0) { + return C.POSITION_UNSET; + } + return discardSamples(length); + } + + private void releaseDrmSessionReferences() { + if (currentDrmSession != null) { + currentDrmSession.release(); + currentDrmSession = null; + // Clear downstream format to avoid violating the assumption that downstreamFormat.drmInitData + // != null implies currentSession != null + downstreamFormat = null; + } + } + + private synchronized void commitSample( + long timeUs, @C.BufferFlags int sampleFlags, long offset, int size, CryptoData cryptoData) { + if (upstreamKeyframeRequired) { + if ((sampleFlags & C.BUFFER_FLAG_KEY_FRAME) == 0) { + return; + } + upstreamKeyframeRequired = false; + } + Assertions.checkState(!upstreamFormatRequired); + + isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); + + int relativeEndIndex = getRelativeIndex(length); + timesUs[relativeEndIndex] = timeUs; + offsets[relativeEndIndex] = offset; + sizes[relativeEndIndex] = size; + flags[relativeEndIndex] = sampleFlags; + cryptoDatas[relativeEndIndex] = cryptoData; + formats[relativeEndIndex] = upstreamFormat; + sourceIds[relativeEndIndex] = upstreamSourceId; + upstreamCommittedFormat = upstreamFormat; + + length++; + if (length == capacity) { + // Increase the capacity. + int newCapacity = capacity + SAMPLE_CAPACITY_INCREMENT; + int[] newSourceIds = new int[newCapacity]; + long[] newOffsets = new long[newCapacity]; + long[] newTimesUs = new long[newCapacity]; + int[] newFlags = new int[newCapacity]; + int[] newSizes = new int[newCapacity]; + CryptoData[] newCryptoDatas = new CryptoData[newCapacity]; + Format[] newFormats = new Format[newCapacity]; + int beforeWrap = capacity - relativeFirstIndex; + System.arraycopy(offsets, relativeFirstIndex, newOffsets, 0, beforeWrap); + System.arraycopy(timesUs, relativeFirstIndex, newTimesUs, 0, beforeWrap); + System.arraycopy(flags, relativeFirstIndex, newFlags, 0, beforeWrap); + System.arraycopy(sizes, relativeFirstIndex, newSizes, 0, beforeWrap); + System.arraycopy(cryptoDatas, relativeFirstIndex, newCryptoDatas, 0, beforeWrap); + System.arraycopy(formats, relativeFirstIndex, newFormats, 0, beforeWrap); + System.arraycopy(sourceIds, relativeFirstIndex, newSourceIds, 0, beforeWrap); + int afterWrap = relativeFirstIndex; + System.arraycopy(offsets, 0, newOffsets, beforeWrap, afterWrap); + System.arraycopy(timesUs, 0, newTimesUs, beforeWrap, afterWrap); + System.arraycopy(flags, 0, newFlags, beforeWrap, afterWrap); + System.arraycopy(sizes, 0, newSizes, beforeWrap, afterWrap); + System.arraycopy(cryptoDatas, 0, newCryptoDatas, beforeWrap, afterWrap); + System.arraycopy(formats, 0, newFormats, beforeWrap, afterWrap); + System.arraycopy(sourceIds, 0, newSourceIds, beforeWrap, afterWrap); + offsets = newOffsets; + timesUs = newTimesUs; + flags = newFlags; + sizes = newSizes; + cryptoDatas = newCryptoDatas; + formats = newFormats; + sourceIds = newSourceIds; + relativeFirstIndex = 0; + length = capacity; + capacity = newCapacity; + } + } /** - * Clears allocation nodes starting from {@code fromNode}. + * Attempts to discard samples from the end of the queue to allow samples starting from the + * specified timestamp to be spliced in. Samples will not be discarded prior to the read position. * - * @param fromNode The node from which to clear. + * @param timeUs The timestamp at which the splice occurs. + * @return Whether the splice was successful. */ - private void clearAllocationNodes(AllocationNode fromNode) { - if (!fromNode.wasInitialized) { + private synchronized boolean attemptSplice(long timeUs) { + if (length == 0) { + return timeUs > largestDiscardedTimestampUs; + } + long largestReadTimestampUs = + Math.max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); + if (largestReadTimestampUs >= timeUs) { + return false; + } + int retainCount = length; + int relativeSampleIndex = getRelativeIndex(length - 1); + while (retainCount > readPosition && timesUs[relativeSampleIndex] >= timeUs) { + retainCount--; + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + discardUpstreamSampleMetadata(absoluteFirstIndex + retainCount); + return true; + } + + private long discardUpstreamSampleMetadata(int discardFromIndex) { + int discardCount = getWriteIndex() - discardFromIndex; + Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); + length -= discardCount; + largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); + isLastSampleQueued = discardCount == 0 && isLastSampleQueued; + if (length != 0) { + int relativeLastWriteIndex = getRelativeIndex(length - 1); + return offsets[relativeLastWriteIndex] + sizes[relativeLastWriteIndex]; + } + return 0; + } + + private boolean hasNextSample() { + return readPosition != length; + } + + /** + * Sets the downstream format, performs DRM resource management, and populates the {@code + * outputFormatHolder}. + * + * @param newFormat The new downstream format. + * @param outputFormatHolder The output {@link FormatHolder}. + */ + private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { + outputFormatHolder.format = newFormat; + boolean isFirstFormat = downstreamFormat == null; + DrmInitData oldDrmInitData = isFirstFormat ? null : downstreamFormat.drmInitData; + downstreamFormat = newFormat; + if (drmSessionManager == DrmSessionManager.DUMMY) { + // Avoid attempting to acquire a session using the dummy DRM session manager. It's likely that + // the media source creation has not yet been migrated and the renderer can acquire the + // session for the read DRM init data. + // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. return; } - // Bulk release allocations for performance (it's significantly faster when using - // DefaultAllocator because the allocator's lock only needs to be acquired and released once) - // [Internal: See b/29542039]. - int allocationCount = (writeAllocationNode.wasInitialized ? 1 : 0) - + ((int) (writeAllocationNode.startPosition - fromNode.startPosition) / allocationLength); - Allocation[] allocationsToRelease = new Allocation[allocationCount]; - AllocationNode currentNode = fromNode; - for (int i = 0; i < allocationsToRelease.length; i++) { - allocationsToRelease[i] = currentNode.allocation; - currentNode = currentNode.clear(); + DrmInitData newDrmInitData = newFormat.drmInitData; + outputFormatHolder.includesDrmSession = true; + outputFormatHolder.drmSession = currentDrmSession; + if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { + // Nothing to do. + return; + } + // Ensure we acquire the new session before releasing the previous one in case the same session + // is being used for both DrmInitData. + DrmSession previousSession = currentDrmSession; + Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); + currentDrmSession = + newDrmInitData != null + ? drmSessionManager.acquireSession(playbackLooper, newDrmInitData) + : drmSessionManager.acquirePlaceholderSession( + playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); + outputFormatHolder.drmSession = currentDrmSession; + + if (previousSession != null) { + previousSession.release(); } - allocator.release(allocationsToRelease); } /** - * Called before writing sample data to {@link #writeAllocationNode}. May cause - * {@link #writeAllocationNode} to be initialized. + * Returns whether it's possible to read the next sample. * - * @param length The number of bytes that the caller wishes to write. - * @return The number of bytes that the caller is permitted to write, which may be less than - * {@code length}. + * @param relativeReadIndex The relative read index of the next sample. + * @return Whether it's possible to read the next sample. */ - private int preAppend(int length) { - if (!writeAllocationNode.wasInitialized) { - writeAllocationNode.initialize(allocator.allocate(), - new AllocationNode(writeAllocationNode.endPosition, allocationLength)); + private boolean mayReadSample(int relativeReadIndex) { + if (drmSessionManager == DrmSessionManager.DUMMY) { + // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. + // For protected content it's likely that the DrmSessionManager is still being injected into + // the renderers. We assume that the renderers will be able to acquire a DrmSession if needed. + return true; } - return Math.min(length, (int) (writeAllocationNode.endPosition - totalBytesWritten)); + return currentDrmSession == null + || currentDrmSession.getState() == DrmSession.STATE_OPENED_WITH_KEYS + || ((flags[relativeReadIndex] & C.BUFFER_FLAG_ENCRYPTED) == 0 + && currentDrmSession.playClearSamplesWithoutKeys()); } /** - * Called after writing sample data. May cause {@link #writeAllocationNode} to be advanced. + * Finds the sample in the specified range that's before or at the specified time. If {@code + * keyframe} is {@code true} then the sample is additionally required to be a keyframe. * - * @param length The number of bytes that were written. + * @param relativeStartIndex The relative index from which to start searching. + * @param length The length of the range being searched. + * @param timeUs The specified time. + * @param keyframe Whether only keyframes should be considered. + * @return The offset from {@code relativeFirstIndex} to the found sample, or -1 if no matching + * sample was found. */ - private void postAppend(int length) { - totalBytesWritten += length; - if (totalBytesWritten == writeAllocationNode.endPosition) { - writeAllocationNode = writeAllocationNode.next; + private int findSampleBefore(int relativeStartIndex, int length, long timeUs, boolean keyframe) { + // This could be optimized to use a binary search, however in practice callers to this method + // normally pass times near to the start of the search region. Hence it's unclear whether + // switching to a binary search would yield any real benefit. + int sampleCountToTarget = -1; + int searchIndex = relativeStartIndex; + for (int i = 0; i < length && timesUs[searchIndex] <= timeUs; i++) { + if (!keyframe || (flags[searchIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + // We've found a suitable sample. + sampleCountToTarget = i; + } + searchIndex++; + if (searchIndex == capacity) { + searchIndex = 0; + } } + return sampleCountToTarget; + } + + /** + * Discards the specified number of samples. + * + * @param discardCount The number of samples to discard. + * @return The corresponding offset up to which data should be discarded. + */ + private long discardSamples(int discardCount) { + largestDiscardedTimestampUs = + Math.max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); + length -= discardCount; + absoluteFirstIndex += discardCount; + relativeFirstIndex += discardCount; + if (relativeFirstIndex >= capacity) { + relativeFirstIndex -= capacity; + } + readPosition -= discardCount; + if (readPosition < 0) { + readPosition = 0; + } + if (length == 0) { + int relativeLastDiscardIndex = (relativeFirstIndex == 0 ? capacity : relativeFirstIndex) - 1; + return offsets[relativeLastDiscardIndex] + sizes[relativeLastDiscardIndex]; + } else { + return offsets[relativeFirstIndex]; + } + } + + /** + * Finds the largest timestamp of any sample from the start of the queue up to the specified + * length, assuming that the timestamps prior to a keyframe are always less than the timestamp of + * the keyframe itself, and of subsequent frames. + * + * @param length The length of the range being searched. + * @return The largest timestamp, or {@link Long#MIN_VALUE} if {@code length == 0}. + */ + private long getLargestTimestamp(int length) { + if (length == 0) { + return Long.MIN_VALUE; + } + long largestTimestampUs = Long.MIN_VALUE; + int relativeSampleIndex = getRelativeIndex(length - 1); + for (int i = 0; i < length; i++) { + largestTimestampUs = Math.max(largestTimestampUs, timesUs[relativeSampleIndex]); + if ((flags[relativeSampleIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + break; + } + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + return largestTimestampUs; + } + + /** + * Returns the relative index for a given offset from the start of the queue. + * + * @param offset The offset, which must be in the range [0, length]. + */ + private int getRelativeIndex(int offset) { + int relativeIndex = relativeFirstIndex + offset; + return relativeIndex < capacity ? relativeIndex : relativeIndex - capacity; } /** @@ -735,76 +875,11 @@ public class SampleQueue implements TrackOutput { return format; } - /** A node in a linked list of {@link Allocation}s held by the output. */ - private static final class AllocationNode { - - /** - * The absolute position of the start of the data (inclusive). - */ - public final long startPosition; - /** - * The absolute position of the end of the data (exclusive). - */ - public final long endPosition; - /** - * Whether the node has been initialized. Remains true after {@link #clear()}. - */ - public boolean wasInitialized; - /** - * The {@link Allocation}, or {@code null} if the node is not initialized. - */ - @Nullable public Allocation allocation; - /** - * The next {@link AllocationNode} in the list, or {@code null} if the node has not been - * initialized. Remains set after {@link #clear()}. - */ - @Nullable public AllocationNode next; - - /** - * @param startPosition See {@link #startPosition}. - * @param allocationLength The length of the {@link Allocation} with which this node will be - * initialized. - */ - public AllocationNode(long startPosition, int allocationLength) { - this.startPosition = startPosition; - this.endPosition = startPosition + allocationLength; - } - - /** - * Initializes the node. - * - * @param allocation The node's {@link Allocation}. - * @param next The next {@link AllocationNode}. - */ - public void initialize(Allocation allocation, AllocationNode next) { - this.allocation = allocation; - this.next = next; - wasInitialized = true; - } - - /** - * Gets the offset into the {@link #allocation}'s {@link Allocation#data} that corresponds to - * the specified absolute position. - * - * @param absolutePosition The absolute position. - * @return The corresponding offset into the allocation's data. - */ - public int translateOffset(long absolutePosition) { - return (int) (absolutePosition - startPosition) + allocation.offset; - } - - /** - * Clears {@link #allocation} and {@link #next}. - * - * @return The cleared next {@link AllocationNode}. - */ - public AllocationNode clear() { - allocation = null; - AllocationNode temp = next; - next = null; - return temp; - } + /** A holder for sample metadata not held by {@link DecoderInputBuffer}. */ + /* package */ static final class SampleExtrasHolder { + public int size; + public long offset; + public CryptoData cryptoData; } - } From 0a0a478294c6ae6799e93a4545835717fbe7a9a1 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 5 Dec 2019 13:46:05 +0000 Subject: [PATCH 0040/1052] Add a test for SampleQueue capacity increases Also remove redundant line PiperOrigin-RevId: 283956203 --- .../exoplayer2/source/SampleQueue.java | 4 +-- .../exoplayer2/source/SampleQueueTest.java | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index 8eb3bfcb0a..cc15d9d275 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import android.os.Looper; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; @@ -49,7 +50,7 @@ public class SampleQueue implements TrackOutput { public static final int ADVANCE_FAILED = -1; - private static final int SAMPLE_CAPACITY_INCREMENT = 1000; + @VisibleForTesting /* package */ static final int SAMPLE_CAPACITY_INCREMENT = 1000; private final SampleDataQueue sampleDataQueue; private final SampleExtrasHolder extrasHolder; @@ -652,7 +653,6 @@ public class SampleQueue implements TrackOutput { formats = newFormats; sourceIds = newSourceIds; relativeFirstIndex = 0; - length = capacity; capacity = newCapacity; } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 441ac9e05a..4823a725c9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -157,6 +157,34 @@ public final class SampleQueueTest { inputBuffer = null; } + @Test + public void testCapacityIncreases() { + int numberOfSamplesToInput = 3 * SampleQueue.SAMPLE_CAPACITY_INCREMENT + 1; + sampleQueue.format(FORMAT_1); + sampleQueue.sampleData( + new ParsableByteArray(numberOfSamplesToInput), /* length= */ numberOfSamplesToInput); + for (int i = 0; i < numberOfSamplesToInput; i++) { + sampleQueue.sampleMetadata( + /* timeUs= */ i * 1000, + /* flags= */ C.BUFFER_FLAG_KEY_FRAME, + /* size= */ 1, + /* offset= */ numberOfSamplesToInput - i - 1, + /* cryptoData= */ null); + } + + assertReadFormat(/* formatRequired= */ false, FORMAT_1); + for (int i = 0; i < numberOfSamplesToInput; i++) { + assertReadSample( + /* timeUs= */ i * 1000, + /* isKeyFrame= */ true, + /* isEncrypted= */ false, + /* sampleData= */ new byte[1], + /* offset= */ 0, + /* length= */ 1); + } + assertReadNothing(/* formatRequired= */ false); + } + @Test public void testResetReleasesAllocations() { writeTestData(); From 023e141be86fa505ce5143bc8063bc5278654501 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 5 Dec 2019 14:14:48 +0000 Subject: [PATCH 0041/1052] Verify metadata in extractor tests PiperOrigin-RevId: 283960017 --- .../src/androidTest/assets/bear.flac.0.dump | 1 + .../src/androidTest/assets/bear.flac.1.dump | 1 + .../src/androidTest/assets/bear.flac.2.dump | 1 + .../src/androidTest/assets/bear.flac.3.dump | 1 + .../assets/bear_with_id3.flac.0.dump | 1 + .../assets/bear_with_id3.flac.1.dump | 1 + .../assets/bear_with_id3.flac.2.dump | 1 + .../assets/bear_with_id3.flac.3.dump | 1 + .../src/test/assets/amr/sample_nb.amr.0.dump | 1 + .../test/assets/amr/sample_nb_cbr.amr.0.dump | 1 + .../test/assets/amr/sample_nb_cbr.amr.1.dump | 1 + .../test/assets/amr/sample_nb_cbr.amr.2.dump | 1 + .../test/assets/amr/sample_nb_cbr.amr.3.dump | 1 + .../assets/amr/sample_nb_cbr.amr.unklen.dump | 1 + .../src/test/assets/amr/sample_wb.amr.0.dump | 1 + .../test/assets/amr/sample_wb_cbr.amr.0.dump | 1 + .../test/assets/amr/sample_wb_cbr.amr.1.dump | 1 + .../test/assets/amr/sample_wb_cbr.amr.2.dump | 1 + .../test/assets/amr/sample_wb_cbr.amr.3.dump | 1 + .../assets/amr/sample_wb_cbr.amr.unklen.dump | 1 + .../src/test/assets/flac/bear.flac.0.dump | 1 + .../bear_no_min_max_frame_size.flac.0.dump | 1 + .../flac/bear_no_num_samples.flac.0.dump | 1 + .../flac/bear_one_metadata_block.flac.0.dump | 1 + .../bear_uncommon_sample_rate.flac.0.dump | 1 + ...h_id3.flac => bear_with_id3_disabled.flac} | Bin ...ump => bear_with_id3_disabled.flac.0.dump} | 1 + .../assets/flac/bear_with_id3_enabled.flac | Bin 0 -> 219715 bytes .../flac/bear_with_id3_enabled.flac.0.dump | 164 ++++++++++++++++++ .../assets/flac/bear_with_picture.flac.0.dump | 1 + .../bear_with_vorbis_comments.flac.0.dump | 1 + .../src/test/assets/flv/sample.flv.0.dump | 2 + .../src/test/assets/mkv/sample.mkv.0.dump | 2 + .../src/test/assets/mkv/sample.mkv.1.dump | 2 + .../src/test/assets/mkv/sample.mkv.2.dump | 2 + .../src/test/assets/mkv/sample.mkv.3.dump | 2 + .../subsample_encrypted_altref.webm.0.dump | 1 + .../subsample_encrypted_noaltref.webm.0.dump | 1 + .../core/src/test/assets/mp3/bear.mp3.0.dump | 1 + .../core/src/test/assets/mp3/bear.mp3.1.dump | 1 + .../core/src/test/assets/mp3/bear.mp3.2.dump | 1 + .../core/src/test/assets/mp3/bear.mp3.3.dump | 1 + .../test/assets/mp3/play-trimmed.mp3.0.dump | 1 + .../test/assets/mp3/play-trimmed.mp3.1.dump | 1 + .../test/assets/mp3/play-trimmed.mp3.2.dump | 1 + .../test/assets/mp3/play-trimmed.mp3.3.dump | 1 + .../assets/mp3/play-trimmed.mp3.unklen.dump | 1 + .../src/test/assets/mp4/sample.mp4.0.dump | 2 + .../src/test/assets/mp4/sample.mp4.1.dump | 2 + .../src/test/assets/mp4/sample.mp4.2.dump | 2 + .../src/test/assets/mp4/sample.mp4.3.dump | 2 + .../assets/mp4/sample_fragmented.mp4.0.dump | 2 + .../mp4/sample_fragmented_seekable.mp4.0.dump | 2 + .../mp4/sample_fragmented_seekable.mp4.1.dump | 2 + .../mp4/sample_fragmented_seekable.mp4.2.dump | 2 + .../mp4/sample_fragmented_seekable.mp4.3.dump | 2 + .../mp4/sample_fragmented_sei.mp4.0.dump | 3 + .../core/src/test/assets/ogg/bear.opus.0.dump | 1 + .../core/src/test/assets/ogg/bear.opus.1.dump | 1 + .../core/src/test/assets/ogg/bear.opus.2.dump | 1 + .../core/src/test/assets/ogg/bear.opus.3.dump | 1 + .../src/test/assets/ogg/bear.opus.unklen.dump | 1 + .../src/test/assets/ogg/bear_flac.ogg.0.dump | 1 + .../src/test/assets/ogg/bear_flac.ogg.1.dump | 1 + .../src/test/assets/ogg/bear_flac.ogg.2.dump | 1 + .../src/test/assets/ogg/bear_flac.ogg.3.dump | 1 + .../test/assets/ogg/bear_flac.ogg.unklen.dump | 1 + .../ogg/bear_flac_noseektable.ogg.0.dump | 1 + .../ogg/bear_flac_noseektable.ogg.1.dump | 1 + .../ogg/bear_flac_noseektable.ogg.2.dump | 1 + .../ogg/bear_flac_noseektable.ogg.3.dump | 1 + .../ogg/bear_flac_noseektable.ogg.unklen.dump | 1 + .../test/assets/ogg/bear_vorbis.ogg.0.dump | 1 + .../test/assets/ogg/bear_vorbis.ogg.1.dump | 1 + .../test/assets/ogg/bear_vorbis.ogg.2.dump | 1 + .../test/assets/ogg/bear_vorbis.ogg.3.dump | 1 + .../assets/ogg/bear_vorbis.ogg.unklen.dump | 1 + .../src/test/assets/rawcc/sample.rawcc.0.dump | 1 + .../core/src/test/assets/ts/sample.ac3.0.dump | 1 + .../core/src/test/assets/ts/sample.ac4.0.dump | 1 + .../src/test/assets/ts/sample.adts.0.dump | 2 + .../src/test/assets/ts/sample.eac3.0.dump | 1 + .../core/src/test/assets/ts/sample.ps.0.dump | 2 + .../core/src/test/assets/ts/sample.ps.1.dump | 2 + .../core/src/test/assets/ts/sample.ps.2.dump | 2 + .../core/src/test/assets/ts/sample.ps.3.dump | 2 + .../src/test/assets/ts/sample.ps.unklen.dump | 2 + .../core/src/test/assets/ts/sample.ts.0.dump | 3 + .../core/src/test/assets/ts/sample.ts.1.dump | 3 + .../core/src/test/assets/ts/sample.ts.2.dump | 3 + .../core/src/test/assets/ts/sample.ts.3.dump | 3 + .../src/test/assets/ts/sample.ts.unklen.dump | 3 + .../src/test/assets/ts/sample_cbs.adts.0.dump | 2 + .../src/test/assets/ts/sample_cbs.adts.1.dump | 2 + .../src/test/assets/ts/sample_cbs.adts.2.dump | 2 + .../src/test/assets/ts/sample_cbs.adts.3.dump | 2 + .../assets/ts/sample_cbs.adts.unklen.dump | 2 + .../ts/sample_cbs_truncated.adts.0.dump | 2 + .../ts/sample_cbs_truncated.adts.1.dump | 2 + .../ts/sample_cbs_truncated.adts.2.dump | 2 + .../ts/sample_cbs_truncated.adts.3.dump | 2 + .../ts/sample_cbs_truncated.adts.unklen.dump | 2 + .../src/test/assets/wav/sample.wav.0.dump | 1 + .../src/test/assets/wav/sample.wav.1.dump | 1 + .../src/test/assets/wav/sample.wav.2.dump | 1 + .../src/test/assets/wav/sample.wav.3.dump | 1 + .../extractor/flac/FlacExtractorTest.java | 9 +- .../exoplayer2/testutil/ExtractorAsserts.java | 1 - .../exoplayer2/testutil/FakeTrackOutput.java | 6 +- 109 files changed, 317 insertions(+), 8 deletions(-) rename library/core/src/test/assets/flac/{bear_with_id3.flac => bear_with_id3_disabled.flac} (100%) rename library/core/src/test/assets/flac/{bear_with_id3.flac.0.dump => bear_with_id3_disabled.flac.0.dump} (99%) create mode 100644 library/core/src/test/assets/flac/bear_with_id3_enabled.flac create mode 100644 library/core/src/test/assets/flac/bear_with_id3_enabled.flac.0.dump diff --git a/extensions/flac/src/androidTest/assets/bear.flac.0.dump b/extensions/flac/src/androidTest/assets/bear.flac.0.dump index 87060e8d61..816356a1e6 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.0.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: total output bytes = 526272 sample count = 33 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.1.dump b/extensions/flac/src/androidTest/assets/bear.flac.1.dump index b12f4dbf9d..4a6b06725f 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.1.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: total output bytes = 362432 sample count = 23 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.2.dump b/extensions/flac/src/androidTest/assets/bear.flac.2.dump index 613023e86c..dddb6dc264 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.2.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: total output bytes = 182208 sample count = 12 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.3.dump b/extensions/flac/src/androidTest/assets/bear.flac.3.dump index 79f369751c..0dbe575ecf 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.3.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: total output bytes = 18368 sample count = 2 diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump index 3a3ba57572..59a9f37443 100644 --- a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] initializationData: total output bytes = 526272 sample count = 33 diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump index a07fcaa0a2..a2ad67c9e4 100644 --- a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] initializationData: total output bytes = 362432 sample count = 23 diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump index c4d13dd2e6..067d67f9b8 100644 --- a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] initializationData: total output bytes = 182208 sample count = 12 diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump index 2f389909e7..6edec0017d 100644 --- a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] initializationData: total output bytes = 18368 sample count = 2 diff --git a/library/core/src/test/assets/amr/sample_nb.amr.0.dump b/library/core/src/test/assets/amr/sample_nb.amr.0.dump index e0dec9c62c..596f85bb74 100644 --- a/library/core/src/test/assets/amr/sample_nb.amr.0.dump +++ b/library/core/src/test/assets/amr/sample_nb.amr.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 2834 sample count = 218 diff --git a/library/core/src/test/assets/amr/sample_nb_cbr.amr.0.dump b/library/core/src/test/assets/amr/sample_nb_cbr.amr.0.dump index e8ba3c3588..40d99d3a85 100644 --- a/library/core/src/test/assets/amr/sample_nb_cbr.amr.0.dump +++ b/library/core/src/test/assets/amr/sample_nb_cbr.amr.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 2834 sample count = 218 diff --git a/library/core/src/test/assets/amr/sample_nb_cbr.amr.1.dump b/library/core/src/test/assets/amr/sample_nb_cbr.amr.1.dump index d00ae65c7e..774593c0fd 100644 --- a/library/core/src/test/assets/amr/sample_nb_cbr.amr.1.dump +++ b/library/core/src/test/assets/amr/sample_nb_cbr.amr.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 1898 sample count = 146 diff --git a/library/core/src/test/assets/amr/sample_nb_cbr.amr.2.dump b/library/core/src/test/assets/amr/sample_nb_cbr.amr.2.dump index f68b6df3a3..ed109b4e1d 100644 --- a/library/core/src/test/assets/amr/sample_nb_cbr.amr.2.dump +++ b/library/core/src/test/assets/amr/sample_nb_cbr.amr.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 949 sample count = 73 diff --git a/library/core/src/test/assets/amr/sample_nb_cbr.amr.3.dump b/library/core/src/test/assets/amr/sample_nb_cbr.amr.3.dump index da907e004f..c06de6455c 100644 --- a/library/core/src/test/assets/amr/sample_nb_cbr.amr.3.dump +++ b/library/core/src/test/assets/amr/sample_nb_cbr.amr.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 13 sample count = 1 diff --git a/library/core/src/test/assets/amr/sample_nb_cbr.amr.unklen.dump b/library/core/src/test/assets/amr/sample_nb_cbr.amr.unklen.dump index e0dec9c62c..596f85bb74 100644 --- a/library/core/src/test/assets/amr/sample_nb_cbr.amr.unklen.dump +++ b/library/core/src/test/assets/amr/sample_nb_cbr.amr.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 2834 sample count = 218 diff --git a/library/core/src/test/assets/amr/sample_wb.amr.0.dump b/library/core/src/test/assets/amr/sample_wb.amr.0.dump index 1b3b8bd0dd..c744d9b9c9 100644 --- a/library/core/src/test/assets/amr/sample_wb.amr.0.dump +++ b/library/core/src/test/assets/amr/sample_wb.amr.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 4056 sample count = 169 diff --git a/library/core/src/test/assets/amr/sample_wb_cbr.amr.0.dump b/library/core/src/test/assets/amr/sample_wb_cbr.amr.0.dump index c987c6e357..71c6868da0 100644 --- a/library/core/src/test/assets/amr/sample_wb_cbr.amr.0.dump +++ b/library/core/src/test/assets/amr/sample_wb_cbr.amr.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 4056 sample count = 169 diff --git a/library/core/src/test/assets/amr/sample_wb_cbr.amr.1.dump b/library/core/src/test/assets/amr/sample_wb_cbr.amr.1.dump index fad4565195..1c2318d54a 100644 --- a/library/core/src/test/assets/amr/sample_wb_cbr.amr.1.dump +++ b/library/core/src/test/assets/amr/sample_wb_cbr.amr.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 2712 sample count = 113 diff --git a/library/core/src/test/assets/amr/sample_wb_cbr.amr.2.dump b/library/core/src/test/assets/amr/sample_wb_cbr.amr.2.dump index 1f00a90739..8489ad9b56 100644 --- a/library/core/src/test/assets/amr/sample_wb_cbr.amr.2.dump +++ b/library/core/src/test/assets/amr/sample_wb_cbr.amr.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 1368 sample count = 57 diff --git a/library/core/src/test/assets/amr/sample_wb_cbr.amr.3.dump b/library/core/src/test/assets/amr/sample_wb_cbr.amr.3.dump index 1ec8c6fdb7..e18a4ad7ab 100644 --- a/library/core/src/test/assets/amr/sample_wb_cbr.amr.3.dump +++ b/library/core/src/test/assets/amr/sample_wb_cbr.amr.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 24 sample count = 1 diff --git a/library/core/src/test/assets/amr/sample_wb_cbr.amr.unklen.dump b/library/core/src/test/assets/amr/sample_wb_cbr.amr.unklen.dump index 1b3b8bd0dd..c744d9b9c9 100644 --- a/library/core/src/test/assets/amr/sample_wb_cbr.amr.unklen.dump +++ b/library/core/src/test/assets/amr/sample_wb_cbr.amr.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 4056 sample count = 169 diff --git a/library/core/src/test/assets/flac/bear.flac.0.dump b/library/core/src/test/assets/flac/bear.flac.0.dump index e35dcc2081..109cc49ebb 100644 --- a/library/core/src/test/assets/flac/bear.flac.0.dump +++ b/library/core/src/test/assets/flac/bear.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump index 2c394e71b7..a7c8b628ba 100644 --- a/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 9218FDB7 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump b/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump index c913738be5..7606154ddd 100644 --- a/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 49FA2C21 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump b/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump index e35dcc2081..109cc49ebb 100644 --- a/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump b/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump index 6ad50afc29..488517947c 100644 --- a/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 7249A1B8 total output bytes = 144086 diff --git a/library/core/src/test/assets/flac/bear_with_id3.flac b/library/core/src/test/assets/flac/bear_with_id3_disabled.flac similarity index 100% rename from library/core/src/test/assets/flac/bear_with_id3.flac rename to library/core/src/test/assets/flac/bear_with_id3_disabled.flac diff --git a/library/core/src/test/assets/flac/bear_with_id3.flac.0.dump b/library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump similarity index 99% rename from library/core/src/test/assets/flac/bear_with_id3.flac.0.dump rename to library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump index e35dcc2081..109cc49ebb 100644 --- a/library/core/src/test/assets/flac/bear_with_id3.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_with_id3_enabled.flac b/library/core/src/test/assets/flac/bear_with_id3_enabled.flac new file mode 100644 index 0000000000000000000000000000000000000000..3cfa81e554283a29a56f75a8bf52f653adf9d10e GIT binary patch literal 219715 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 arcX4$!}PZ9>Fzt1?epsEs1A~?ApQYe8d3`Y literal 0 HcmV?d00001 diff --git a/library/core/src/test/assets/flac/bear_with_id3_enabled.flac.0.dump b/library/core/src/test/assets/flac/bear_with_id3_enabled.flac.0.dump new file mode 100644 index 0000000000..47402623c1 --- /dev/null +++ b/library/core/src/test/assets/flac/bear_with_id3_enabled.flac.0.dump @@ -0,0 +1,164 @@ +seekMap: + isSeekable = false + duration = 2741000 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + format: + bitrate = 1536000 + id = null + containerMimeType = null + sampleMimeType = audio/flac + maxInputSize = 5776 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] + initializationData: + data = length 42, hash 83F6895 + total output bytes = 164431 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 5030, hash D2B60530 + sample 1: + time = 85333 + flags = 1 + data = length 5066, hash 4C932A54 + sample 2: + time = 170666 + flags = 1 + data = length 5112, hash 7E5A7B61 + sample 3: + time = 256000 + flags = 1 + data = length 5044, hash 7EF93F13 + sample 4: + time = 341333 + flags = 1 + data = length 4943, hash DE7E27F8 + sample 5: + time = 426666 + flags = 1 + data = length 5121, hash 6D0D0B40 + sample 6: + time = 512000 + flags = 1 + data = length 5068, hash 9924644F + sample 7: + time = 597333 + flags = 1 + data = length 5143, hash 6C34F0CE + sample 8: + time = 682666 + flags = 1 + data = length 5109, hash E3B7BEFB + sample 9: + time = 768000 + flags = 1 + data = length 5129, hash 44111D9B + sample 10: + time = 853333 + flags = 1 + data = length 5031, hash 9D55EA53 + sample 11: + time = 938666 + flags = 1 + data = length 5119, hash E1CB9BA6 + sample 12: + time = 1024000 + flags = 1 + data = length 5360, hash 17265C5D + sample 13: + time = 1109333 + flags = 1 + data = length 5340, hash A90FDDF1 + sample 14: + time = 1194666 + flags = 1 + data = length 5162, hash 31F65AD5 + sample 15: + time = 1280000 + flags = 1 + data = length 5168, hash F2394F2D + sample 16: + time = 1365333 + flags = 1 + data = length 5776, hash 58437AB3 + sample 17: + time = 1450666 + flags = 1 + data = length 5394, hash EBAB20A8 + sample 18: + time = 1536000 + flags = 1 + data = length 5168, hash BF37C7A5 + sample 19: + time = 1621333 + flags = 1 + data = length 5324, hash 59546B7B + sample 20: + time = 1706666 + flags = 1 + data = length 5172, hash 6036EF0B + sample 21: + time = 1792000 + flags = 1 + data = length 5102, hash 5A131071 + sample 22: + time = 1877333 + flags = 1 + data = length 5111, hash 3D9EBB3B + sample 23: + time = 1962666 + flags = 1 + data = length 5113, hash 61101D4F + sample 24: + time = 2048000 + flags = 1 + data = length 5229, hash D2E55742 + sample 25: + time = 2133333 + flags = 1 + data = length 5162, hash 7F2E97FA + sample 26: + time = 2218666 + flags = 1 + data = length 5255, hash D92A782 + sample 27: + time = 2304000 + flags = 1 + data = length 5196, hash 98FE5138 + sample 28: + time = 2389333 + flags = 1 + data = length 5214, hash 3D35C38C + sample 29: + time = 2474666 + flags = 1 + data = length 5211, hash 7E25420F + sample 30: + time = 2560000 + flags = 1 + data = length 5230, hash 2AD96FBC + sample 31: + time = 2645333 + flags = 1 + data = length 3384, hash 938BCDD9 + sample 32: + time = 2730666 + flags = 1 + data = length 445, hash A388E3D6 +tracksEnded = true diff --git a/library/core/src/test/assets/flac/bear_with_picture.flac.0.dump b/library/core/src/test/assets/flac/bear_with_picture.flac.0.dump index e35dcc2081..93e33e7c23 100644 --- a/library/core/src/test/assets/flac/bear_with_picture.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_with_picture.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[Picture: mimeType=image/png, description=] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump b/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump index e35dcc2081..f7f67b12ab 100644 --- a/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_with_vorbis_comments.flac.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[VC: TITLE=test title, VC: ARTIST=test artist] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flv/sample.flv.0.dump b/library/core/src/test/assets/flv/sample.flv.0.dump index 098311a310..753896a112 100644 --- a/library/core/src/test/assets/flv/sample.flv.0.dump +++ b/library/core/src/test/assets/flv/sample.flv.0.dump @@ -24,6 +24,7 @@ track 8: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 9529 @@ -229,6 +230,7 @@ track 9: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B diff --git a/library/core/src/test/assets/mkv/sample.mkv.0.dump b/library/core/src/test/assets/mkv/sample.mkv.0.dump index d91f845199..d53aa8e5ad 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.0.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.0.dump @@ -24,6 +24,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 2: selectionFlags = 1 language = und drmInitData = - + metadata = null initializationData: total output bytes = 12120 sample count = 29 diff --git a/library/core/src/test/assets/mkv/sample.mkv.1.dump b/library/core/src/test/assets/mkv/sample.mkv.1.dump index d9013a762e..3bc74fa08c 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.1.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.1.dump @@ -24,6 +24,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B @@ -130,6 +131,7 @@ track 2: selectionFlags = 1 language = und drmInitData = - + metadata = null initializationData: total output bytes = 8778 sample count = 21 diff --git a/library/core/src/test/assets/mkv/sample.mkv.2.dump b/library/core/src/test/assets/mkv/sample.mkv.2.dump index b0f943e2f2..1978df9fb3 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.2.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.2.dump @@ -24,6 +24,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B @@ -86,6 +87,7 @@ track 2: selectionFlags = 1 language = und drmInitData = - + metadata = null initializationData: total output bytes = 4180 sample count = 10 diff --git a/library/core/src/test/assets/mkv/sample.mkv.3.dump b/library/core/src/test/assets/mkv/sample.mkv.3.dump index 460aca0e90..fb88891571 100644 --- a/library/core/src/test/assets/mkv/sample.mkv.3.dump +++ b/library/core/src/test/assets/mkv/sample.mkv.3.dump @@ -24,6 +24,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash F6F3D010 data = length 10, hash 7A0D0F2B @@ -50,6 +51,7 @@ track 2: selectionFlags = 1 language = und drmInitData = - + metadata = null initializationData: total output bytes = 1254 sample count = 3 diff --git a/library/core/src/test/assets/mkv/subsample_encrypted_altref.webm.0.dump b/library/core/src/test/assets/mkv/subsample_encrypted_altref.webm.0.dump index 89a7514784..e20c21d9ea 100644 --- a/library/core/src/test/assets/mkv/subsample_encrypted_altref.webm.0.dump +++ b/library/core/src/test/assets/mkv/subsample_encrypted_altref.webm.0.dump @@ -24,6 +24,7 @@ track 1: selectionFlags = 0 language = null drmInitData = 1305012705 + metadata = null initializationData: total output bytes = 39 sample count = 1 diff --git a/library/core/src/test/assets/mkv/subsample_encrypted_noaltref.webm.0.dump b/library/core/src/test/assets/mkv/subsample_encrypted_noaltref.webm.0.dump index 1caa3f9f27..904ca8715e 100644 --- a/library/core/src/test/assets/mkv/subsample_encrypted_noaltref.webm.0.dump +++ b/library/core/src/test/assets/mkv/subsample_encrypted_noaltref.webm.0.dump @@ -24,6 +24,7 @@ track 1: selectionFlags = 0 language = null drmInitData = 1305012705 + metadata = null initializationData: total output bytes = 24 sample count = 1 diff --git a/library/core/src/test/assets/mp3/bear.mp3.0.dump b/library/core/src/test/assets/mp3/bear.mp3.0.dump index 5c8700fed1..e597f0a721 100644 --- a/library/core/src/test/assets/mp3/bear.mp3.0.dump +++ b/library/core/src/test/assets/mp3/bear.mp3.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf54.20.4] initializationData: total output bytes = 44544 sample count = 116 diff --git a/library/core/src/test/assets/mp3/bear.mp3.1.dump b/library/core/src/test/assets/mp3/bear.mp3.1.dump index c2f37973b7..a7f7f699fb 100644 --- a/library/core/src/test/assets/mp3/bear.mp3.1.dump +++ b/library/core/src/test/assets/mp3/bear.mp3.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf54.20.4] initializationData: total output bytes = 29568 sample count = 77 diff --git a/library/core/src/test/assets/mp3/bear.mp3.2.dump b/library/core/src/test/assets/mp3/bear.mp3.2.dump index 543cf44cc0..981a141120 100644 --- a/library/core/src/test/assets/mp3/bear.mp3.2.dump +++ b/library/core/src/test/assets/mp3/bear.mp3.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf54.20.4] initializationData: total output bytes = 14592 sample count = 38 diff --git a/library/core/src/test/assets/mp3/bear.mp3.3.dump b/library/core/src/test/assets/mp3/bear.mp3.3.dump index a87b7d6d37..744244bf47 100644 --- a/library/core/src/test/assets/mp3/bear.mp3.3.dump +++ b/library/core/src/test/assets/mp3/bear.mp3.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf54.20.4] initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump index 96b0cd259c..75cf53241f 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 418 sample count = 1 diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump index 96b0cd259c..75cf53241f 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 418 sample count = 1 diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump index 96b0cd259c..75cf53241f 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 418 sample count = 1 diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump index 96b0cd259c..75cf53241f 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 418 sample count = 1 diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.unklen.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.unklen.dump index d28cca025b..a7b96270cd 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.unklen.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 418 sample count = 1 diff --git a/library/core/src/test/assets/mp4/sample.mp4.0.dump b/library/core/src/test/assets/mp4/sample.mp4.0.dump index 37e1054f79..1b5246529b 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 9529 diff --git a/library/core/src/test/assets/mp4/sample.mp4.1.dump b/library/core/src/test/assets/mp4/sample.mp4.1.dump index 6284e85034..666e4b7aea 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 7464 diff --git a/library/core/src/test/assets/mp4/sample.mp4.2.dump b/library/core/src/test/assets/mp4/sample.mp4.2.dump index 15b56a036f..8985adc95b 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 4019 diff --git a/library/core/src/test/assets/mp4/sample.mp4.3.dump b/library/core/src/test/assets/mp4/sample.mp4.3.dump index 073d5c774a..d902c21ed8 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = entries=[TSSE: description=null: value=Lavf56.1.0] initializationData: data = length 2, hash 5F7 total output bytes = 470 diff --git a/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump index faa8a015ca..65f59d78b5 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: data = length 5, hash 2B7623A total output bytes = 18257 diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump index 04e2f6f0a0..27838bd2a8 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: data = length 5, hash 2B7623A total output bytes = 18257 diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump index 48a7623a7d..ea6deafcad 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: data = length 5, hash 2B7623A total output bytes = 13359 diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump index 7522891e14..d14025e0b1 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: data = length 5, hash 2B7623A total output bytes = 6804 diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump index afd24e40ce..d08a1e93ad 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: data = length 5, hash 2B7623A total output bytes = 10 diff --git a/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump index 87f2cc6714..d596a77f78 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 29, hash 4746B5D9 data = length 10, hash 7A0D0F2B @@ -170,6 +171,7 @@ track 1: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: data = length 5, hash 2B7623A total output bytes = 18257 @@ -379,6 +381,7 @@ track 3: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ogg/bear.opus.0.dump b/library/core/src/test/assets/ogg/bear.opus.0.dump index f8eadb16fa..4207420d5a 100644 --- a/library/core/src/test/assets/ogg/bear.opus.0.dump +++ b/library/core/src/test/assets/ogg/bear.opus.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 19, hash BFE794DB data = length 8, hash CA22068C diff --git a/library/core/src/test/assets/ogg/bear.opus.1.dump b/library/core/src/test/assets/ogg/bear.opus.1.dump index 593116a22e..ffe458f2aa 100644 --- a/library/core/src/test/assets/ogg/bear.opus.1.dump +++ b/library/core/src/test/assets/ogg/bear.opus.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 19, hash BFE794DB data = length 8, hash CA22068C diff --git a/library/core/src/test/assets/ogg/bear.opus.2.dump b/library/core/src/test/assets/ogg/bear.opus.2.dump index beabde35c8..9235aff882 100644 --- a/library/core/src/test/assets/ogg/bear.opus.2.dump +++ b/library/core/src/test/assets/ogg/bear.opus.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 19, hash BFE794DB data = length 8, hash CA22068C diff --git a/library/core/src/test/assets/ogg/bear.opus.3.dump b/library/core/src/test/assets/ogg/bear.opus.3.dump index d0f3e2948b..c19eb7efb2 100644 --- a/library/core/src/test/assets/ogg/bear.opus.3.dump +++ b/library/core/src/test/assets/ogg/bear.opus.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 19, hash BFE794DB data = length 8, hash CA22068C diff --git a/library/core/src/test/assets/ogg/bear.opus.unklen.dump b/library/core/src/test/assets/ogg/bear.opus.unklen.dump index ec8f8b8665..55d08fc273 100644 --- a/library/core/src/test/assets/ogg/bear.opus.unklen.dump +++ b/library/core/src/test/assets/ogg/bear.opus.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 19, hash BFE794DB data = length 8, hash CA22068C diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump index 365040c46c..896c8ad6c5 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump index ff020b32fd..e85b504a39 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 113666 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump index 88deeaebd3..63bc130424 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 55652 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump index 2eb7be2454..fdebce7743 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 445 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump index 365040c46c..896c8ad6c5 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump index c07b2f3844..b09453f208 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump index a7fce3c901..4ab08524ae 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 113666 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump index d05d36bd1e..3a846736d2 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 55652 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump index 376cb68499..5bf1a92472 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 445 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump index 44a93a6037..1a0686c5fd 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = entries=[] initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_vorbis.ogg.0.dump b/library/core/src/test/assets/ogg/bear_vorbis.ogg.0.dump index 138e13c54d..2ce3f2374c 100644 --- a/library/core/src/test/assets/ogg/bear_vorbis.ogg.0.dump +++ b/library/core/src/test/assets/ogg/bear_vorbis.ogg.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash 9A8FF207 data = length 3832, hash 8A406249 diff --git a/library/core/src/test/assets/ogg/bear_vorbis.ogg.1.dump b/library/core/src/test/assets/ogg/bear_vorbis.ogg.1.dump index 6b37dfb6cf..4a8f664d6f 100644 --- a/library/core/src/test/assets/ogg/bear_vorbis.ogg.1.dump +++ b/library/core/src/test/assets/ogg/bear_vorbis.ogg.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash 9A8FF207 data = length 3832, hash 8A406249 diff --git a/library/core/src/test/assets/ogg/bear_vorbis.ogg.2.dump b/library/core/src/test/assets/ogg/bear_vorbis.ogg.2.dump index 9620979357..078970b0ef 100644 --- a/library/core/src/test/assets/ogg/bear_vorbis.ogg.2.dump +++ b/library/core/src/test/assets/ogg/bear_vorbis.ogg.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash 9A8FF207 data = length 3832, hash 8A406249 diff --git a/library/core/src/test/assets/ogg/bear_vorbis.ogg.3.dump b/library/core/src/test/assets/ogg/bear_vorbis.ogg.3.dump index 18d869030d..74ae3cfbd6 100644 --- a/library/core/src/test/assets/ogg/bear_vorbis.ogg.3.dump +++ b/library/core/src/test/assets/ogg/bear_vorbis.ogg.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash 9A8FF207 data = length 3832, hash 8A406249 diff --git a/library/core/src/test/assets/ogg/bear_vorbis.ogg.unklen.dump b/library/core/src/test/assets/ogg/bear_vorbis.ogg.unklen.dump index 2686f740db..0855e961fc 100644 --- a/library/core/src/test/assets/ogg/bear_vorbis.ogg.unklen.dump +++ b/library/core/src/test/assets/ogg/bear_vorbis.ogg.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 30, hash 9A8FF207 data = length 3832, hash 8A406249 diff --git a/library/core/src/test/assets/rawcc/sample.rawcc.0.dump b/library/core/src/test/assets/rawcc/sample.rawcc.0.dump index adeaaf6a37..9aca7479d0 100644 --- a/library/core/src/test/assets/rawcc/sample.rawcc.0.dump +++ b/library/core/src/test/assets/rawcc/sample.rawcc.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 978 sample count = 150 diff --git a/library/core/src/test/assets/ts/sample.ac3.0.dump b/library/core/src/test/assets/ts/sample.ac3.0.dump index a1d29a77dc..de37c06d05 100644 --- a/library/core/src/test/assets/ts/sample.ac3.0.dump +++ b/library/core/src/test/assets/ts/sample.ac3.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 13281 sample count = 8 diff --git a/library/core/src/test/assets/ts/sample.ac4.0.dump b/library/core/src/test/assets/ts/sample.ac4.0.dump index 03ae07707a..9df4b77faf 100644 --- a/library/core/src/test/assets/ts/sample.ac4.0.dump +++ b/library/core/src/test/assets/ts/sample.ac4.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 7594 sample count = 19 diff --git a/library/core/src/test/assets/ts/sample.adts.0.dump b/library/core/src/test/assets/ts/sample.adts.0.dump index 93d7b776c0..9b24043fb3 100644 --- a/library/core/src/test/assets/ts/sample.adts.0.dump +++ b/library/core/src/test/assets/ts/sample.adts.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 30797 @@ -625,6 +626,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample.eac3.0.dump b/library/core/src/test/assets/ts/sample.eac3.0.dump index b0b2779958..5841500a7a 100644 --- a/library/core/src/test/assets/ts/sample.eac3.0.dump +++ b/library/core/src/test/assets/ts/sample.eac3.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 216000 sample count = 54 diff --git a/library/core/src/test/assets/ts/sample.ps.0.dump b/library/core/src/test/assets/ts/sample.ps.0.dump index 06ef48de7a..09b3281b2f 100644 --- a/library/core/src/test/assets/ts/sample.ps.0.dump +++ b/library/core/src/test/assets/ts/sample.ps.0.dump @@ -24,6 +24,7 @@ track 192: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 1671 sample count = 4 @@ -64,6 +65,7 @@ track 224: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash 743CC6F8 total output bytes = 44056 diff --git a/library/core/src/test/assets/ts/sample.ps.1.dump b/library/core/src/test/assets/ts/sample.ps.1.dump index ce0f223bd4..5fc9d32875 100644 --- a/library/core/src/test/assets/ts/sample.ps.1.dump +++ b/library/core/src/test/assets/ts/sample.ps.1.dump @@ -24,6 +24,7 @@ track 192: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 @@ -48,6 +49,7 @@ track 224: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash 743CC6F8 total output bytes = 33949 diff --git a/library/core/src/test/assets/ts/sample.ps.2.dump b/library/core/src/test/assets/ts/sample.ps.2.dump index 7d0a77037d..fefe6fa45c 100644 --- a/library/core/src/test/assets/ts/sample.ps.2.dump +++ b/library/core/src/test/assets/ts/sample.ps.2.dump @@ -24,6 +24,7 @@ track 192: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 @@ -48,6 +49,7 @@ track 224: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash 743CC6F8 total output bytes = 19791 diff --git a/library/core/src/test/assets/ts/sample.ps.3.dump b/library/core/src/test/assets/ts/sample.ps.3.dump index a7258cd7ef..5a97e3a1ae 100644 --- a/library/core/src/test/assets/ts/sample.ps.3.dump +++ b/library/core/src/test/assets/ts/sample.ps.3.dump @@ -24,6 +24,7 @@ track 192: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 @@ -48,6 +49,7 @@ track 224: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash 743CC6F8 total output bytes = 1585 diff --git a/library/core/src/test/assets/ts/sample.ps.unklen.dump b/library/core/src/test/assets/ts/sample.ps.unklen.dump index dda6de8ab4..9e619e94f3 100644 --- a/library/core/src/test/assets/ts/sample.ps.unklen.dump +++ b/library/core/src/test/assets/ts/sample.ps.unklen.dump @@ -24,6 +24,7 @@ track 192: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 1671 sample count = 4 @@ -64,6 +65,7 @@ track 224: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash 743CC6F8 total output bytes = 44056 diff --git a/library/core/src/test/assets/ts/sample.ts.0.dump b/library/core/src/test/assets/ts/sample.ts.0.dump index b45a32fd3a..4bc1978d18 100644 --- a/library/core/src/test/assets/ts/sample.ts.0.dump +++ b/library/core/src/test/assets/ts/sample.ts.0.dump @@ -24,6 +24,7 @@ track 256: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash CE183139 total output bytes = 45026 @@ -57,6 +58,7 @@ track 257: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: total output bytes = 5015 sample count = 4 @@ -97,6 +99,7 @@ track 8448: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample.ts.1.dump b/library/core/src/test/assets/ts/sample.ts.1.dump index 5c361e1246..97471beff8 100644 --- a/library/core/src/test/assets/ts/sample.ts.1.dump +++ b/library/core/src/test/assets/ts/sample.ts.1.dump @@ -24,6 +24,7 @@ track 256: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash CE183139 total output bytes = 45026 @@ -57,6 +58,7 @@ track 257: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: total output bytes = 5015 sample count = 4 @@ -97,6 +99,7 @@ track 8448: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample.ts.2.dump b/library/core/src/test/assets/ts/sample.ts.2.dump index cec91ae2b9..7039c71566 100644 --- a/library/core/src/test/assets/ts/sample.ts.2.dump +++ b/library/core/src/test/assets/ts/sample.ts.2.dump @@ -24,6 +24,7 @@ track 256: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash CE183139 total output bytes = 45026 @@ -57,6 +58,7 @@ track 257: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: total output bytes = 5015 sample count = 4 @@ -97,6 +99,7 @@ track 8448: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample.ts.3.dump b/library/core/src/test/assets/ts/sample.ts.3.dump index d8238e1626..b6c11646a2 100644 --- a/library/core/src/test/assets/ts/sample.ts.3.dump +++ b/library/core/src/test/assets/ts/sample.ts.3.dump @@ -24,6 +24,7 @@ track 256: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash CE183139 total output bytes = 0 @@ -49,6 +50,7 @@ track 257: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: total output bytes = 2508 sample count = 2 @@ -81,6 +83,7 @@ track 8448: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample.ts.unklen.dump b/library/core/src/test/assets/ts/sample.ts.unklen.dump index 56f6b01a9c..904add8f9e 100644 --- a/library/core/src/test/assets/ts/sample.ts.unklen.dump +++ b/library/core/src/test/assets/ts/sample.ts.unklen.dump @@ -24,6 +24,7 @@ track 256: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 22, hash CE183139 total output bytes = 45026 @@ -57,6 +58,7 @@ track 257: selectionFlags = 0 language = und drmInitData = - + metadata = null initializationData: total output bytes = 5015 sample count = 4 @@ -97,6 +99,7 @@ track 8448: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs.adts.0.dump b/library/core/src/test/assets/ts/sample_cbs.adts.0.dump index e535aa8cd7..c823eba0cb 100644 --- a/library/core/src/test/assets/ts/sample_cbs.adts.0.dump +++ b/library/core/src/test/assets/ts/sample_cbs.adts.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 30797 @@ -625,6 +626,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs.adts.1.dump b/library/core/src/test/assets/ts/sample_cbs.adts.1.dump index 96d2fcfb39..ce0a645a0b 100644 --- a/library/core/src/test/assets/ts/sample_cbs.adts.1.dump +++ b/library/core/src/test/assets/ts/sample_cbs.adts.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 20533 @@ -425,6 +426,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs.adts.2.dump b/library/core/src/test/assets/ts/sample_cbs.adts.2.dump index 2e581bca28..efaa192a06 100644 --- a/library/core/src/test/assets/ts/sample_cbs.adts.2.dump +++ b/library/core/src/test/assets/ts/sample_cbs.adts.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 10161 @@ -245,6 +246,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs.adts.3.dump b/library/core/src/test/assets/ts/sample_cbs.adts.3.dump index e134a711bf..892445c542 100644 --- a/library/core/src/test/assets/ts/sample_cbs.adts.3.dump +++ b/library/core/src/test/assets/ts/sample_cbs.adts.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 174 @@ -53,6 +54,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs.adts.unklen.dump b/library/core/src/test/assets/ts/sample_cbs.adts.unklen.dump index 93d7b776c0..9b24043fb3 100644 --- a/library/core/src/test/assets/ts/sample_cbs.adts.unklen.dump +++ b/library/core/src/test/assets/ts/sample_cbs.adts.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 30797 @@ -625,6 +626,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.0.dump b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.0.dump index 3344e7ba59..cf839608a7 100644 --- a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.0.dump +++ b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 30787 @@ -621,6 +622,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.1.dump b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.1.dump index 53608df7ed..8fa122d7ae 100644 --- a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.1.dump +++ b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 20523 @@ -421,6 +422,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.2.dump b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.2.dump index af8e415412..512245a66c 100644 --- a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.2.dump +++ b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 10151 @@ -241,6 +242,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.3.dump b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.3.dump index 9186e04d6f..7168ef4185 100644 --- a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.3.dump +++ b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 164 @@ -49,6 +50,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.unklen.dump b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.unklen.dump index d478f65cac..8d427ba60f 100644 --- a/library/core/src/test/assets/ts/sample_cbs_truncated.adts.unklen.dump +++ b/library/core/src/test/assets/ts/sample_cbs_truncated.adts.unklen.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: data = length 2, hash 5F7 total output bytes = 30787 @@ -621,6 +622,7 @@ track 1: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 0 sample count = 0 diff --git a/library/core/src/test/assets/wav/sample.wav.0.dump b/library/core/src/test/assets/wav/sample.wav.0.dump index a6c46f75fc..fc3ded6ff8 100644 --- a/library/core/src/test/assets/wav/sample.wav.0.dump +++ b/library/core/src/test/assets/wav/sample.wav.0.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 88200 sample count = 3 diff --git a/library/core/src/test/assets/wav/sample.wav.1.dump b/library/core/src/test/assets/wav/sample.wav.1.dump index 3cc70dc71f..f6c120bde5 100644 --- a/library/core/src/test/assets/wav/sample.wav.1.dump +++ b/library/core/src/test/assets/wav/sample.wav.1.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 58802 sample count = 2 diff --git a/library/core/src/test/assets/wav/sample.wav.2.dump b/library/core/src/test/assets/wav/sample.wav.2.dump index 07ce135dfa..bfe175a657 100644 --- a/library/core/src/test/assets/wav/sample.wav.2.dump +++ b/library/core/src/test/assets/wav/sample.wav.2.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 29402 sample count = 1 diff --git a/library/core/src/test/assets/wav/sample.wav.3.dump b/library/core/src/test/assets/wav/sample.wav.3.dump index 82ed95ad60..160a5efdd7 100644 --- a/library/core/src/test/assets/wav/sample.wav.3.dump +++ b/library/core/src/test/assets/wav/sample.wav.3.dump @@ -24,6 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - + metadata = null initializationData: total output bytes = 2 sample count = 1 diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java index c4fd9e21ec..537dbc6efe 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java @@ -31,17 +31,16 @@ public class FlacExtractorTest { @Test public void testSampleWithId3HeaderAndId3Enabled() throws Exception { - ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_with_id3.flac"); + ExtractorAsserts.assertBehavior(FlacExtractor::new, "flac/bear_with_id3_enabled.flac"); } @Test public void testSampleWithId3HeaderAndId3Disabled() throws Exception { - // The same file is used for testing the extractor with and without ID3 enabled as the test does - // not check the metadata outputted. It only checks that the file is parsed correctly in both - // cases. + // bear_with_id3_disabled.flac is identical to bear_with_id3_enabled.flac, but the dump file is + // different due to setting FLAG_DISABLE_ID3_METADATA. ExtractorAsserts.assertBehavior( () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA), - "flac/bear_with_id3.flac"); + "flac/bear_with_id3_disabled.flac"); } @Test diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java index 1e71e0a316..1ca4f1fb18 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java @@ -152,7 +152,6 @@ public final class ExtractorAsserts { assertOutput(factory.create(), file, data, context, false, false, false, false); } - // TODO: Assert format metadata [Internal ref: b/144771011]. /** * Asserts that {@code extractor} consumes {@code sampleFile} successfully and its output equals * to a prerecorded output dump file with the name {@code sampleFile} + "{@value diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java index f78e835b48..0389569a49 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java @@ -162,7 +162,8 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { @Override public void dump(Dumper dumper) { - dumper.startBlock("format") + dumper + .startBlock("format") .add("bitrate", format.bitrate) .add("id", format.id) .add("containerMimeType", format.containerMimeType) @@ -181,7 +182,8 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { .add("subsampleOffsetUs", format.subsampleOffsetUs) .add("selectionFlags", format.selectionFlags) .add("language", format.language) - .add("drmInitData", format.drmInitData != null ? format.drmInitData.hashCode() : "-"); + .add("drmInitData", format.drmInitData != null ? format.drmInitData.hashCode() : "-") + .add("metadata", format.metadata); dumper.startBlock("initializationData"); for (int i = 0; i < format.initializationData.size(); i++) { From a5ee17ec26dc71d1636734124ccd1b10a3cbb234 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 5 Dec 2019 15:08:56 +0000 Subject: [PATCH 0042/1052] Fix casting to not need warning suppression Also remove getRendererCapabilities arg that's now always null. PiperOrigin-RevId: 283966795 --- .../google/android/exoplayer2/demo/PlayerActivity.java | 7 +++---- .../android/exoplayer2/offline/DownloadHelper.java | 6 +++--- .../java/com/google/android/exoplayer2/util/Util.java | 9 ++------- .../android/exoplayer2/offline/DownloadHelperTest.java | 2 +- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 11a4b7216b..87593a7db4 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -507,7 +507,7 @@ public class PlayerActivity extends AppCompatActivity } private MediaSource createLeafMediaSource( - Uri uri, String extension, DrmSessionManager drmSessionManager) { + Uri uri, String extension, DrmSessionManager drmSessionManager) { @ContentType int type = Util.inferContentType(uri, extension); switch (type) { case C.TYPE_DASH: @@ -616,13 +616,12 @@ public class PlayerActivity extends AppCompatActivity MediaSourceFactory adMediaSourceFactory = new MediaSourceFactory() { - private DrmSessionManager drmSessionManager = + private DrmSessionManager drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); @Override - @SuppressWarnings("unchecked") // Safe upcasting. public MediaSourceFactory setDrmSessionManager(DrmSessionManager drmSessionManager) { - this.drmSessionManager = (DrmSessionManager) drmSessionManager; + this.drmSessionManager = drmSessionManager; return this; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index b2641552c0..ff95afb1f6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -269,7 +269,7 @@ public final class DownloadHelper { drmSessionManager, /* streamKeys= */ null), trackSelectorParameters, - Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); + Util.getRendererCapabilities(renderersFactory)); } /** @deprecated Use {@link #forHls(Context, Uri, Factory, RenderersFactory)} */ @@ -339,7 +339,7 @@ public final class DownloadHelper { drmSessionManager, /* streamKeys= */ null), trackSelectorParameters, - Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); + Util.getRendererCapabilities(renderersFactory)); } /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */ @@ -409,7 +409,7 @@ public final class DownloadHelper { drmSessionManager, /* streamKeys= */ null), trackSelectorParameters, - Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); + Util.getRendererCapabilities(renderersFactory)); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index 0ee52dba2b..41465d8052 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -54,8 +54,6 @@ import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.io.ByteArrayOutputStream; @@ -1979,13 +1977,10 @@ public final class Util { * Extract renderer capabilities for the renderers created by the provided renderers factory. * * @param renderersFactory A {@link RenderersFactory}. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers. * @return The {@link RendererCapabilities} for each renderer created by the {@code * renderersFactory}. */ - public static RendererCapabilities[] getRendererCapabilities( - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager) { + public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { Renderer[] renderers = renderersFactory.createRenderers( new Handler(), @@ -1993,7 +1988,7 @@ public final class Util { new AudioRendererEventListener() {}, (cues) -> {}, (metadata) -> {}, - drmSessionManager); + /* drmSessionManager= */ null); RendererCapabilities[] capabilities = new RendererCapabilities[renderers.length]; for (int i = 0; i < renderers.length; i++) { capabilities[i] = renderers[i].getCapabilities(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index f99864440d..49c8302d30 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -115,7 +115,7 @@ public class DownloadHelperTest { TEST_CACHE_KEY, new TestMediaSource(), DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, - Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); + Util.getRendererCapabilities(renderersFactory)); } @Test From 54413c26d423e0fa8adc868d12bb62e3fb54a84d Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 6 Dec 2019 14:07:28 +0000 Subject: [PATCH 0043/1052] Make metadata field nullable in FlacStreamMetadata This makes the format metadata null (instead of an empty Metadata object) when it is not provided, and is therefore consistent with the other extractors behavior. PiperOrigin-RevId: 284171148 --- .../src/androidTest/assets/bear.flac.0.dump | 2 +- .../src/androidTest/assets/bear.flac.1.dump | 2 +- .../src/androidTest/assets/bear.flac.2.dump | 2 +- .../src/androidTest/assets/bear.flac.3.dump | 2 +- .../exoplayer2/ext/flac/FlacExtractor.java | 1 + .../extractor/FlacMetadataReader.java | 3 ++- .../exoplayer2/util/FlacStreamMetadata.java | 25 +++++++++++-------- .../src/test/assets/flac/bear.flac.0.dump | 2 +- .../bear_no_min_max_frame_size.flac.0.dump | 2 +- .../flac/bear_no_num_samples.flac.0.dump | 2 +- .../flac/bear_one_metadata_block.flac.0.dump | 2 +- .../bear_uncommon_sample_rate.flac.0.dump | 2 +- .../flac/bear_with_id3_disabled.flac.0.dump | 2 +- .../src/test/assets/ogg/bear_flac.ogg.0.dump | 2 +- .../src/test/assets/ogg/bear_flac.ogg.1.dump | 2 +- .../src/test/assets/ogg/bear_flac.ogg.2.dump | 2 +- .../src/test/assets/ogg/bear_flac.ogg.3.dump | 2 +- .../test/assets/ogg/bear_flac.ogg.unklen.dump | 2 +- .../ogg/bear_flac_noseektable.ogg.0.dump | 2 +- .../ogg/bear_flac_noseektable.ogg.1.dump | 2 +- .../ogg/bear_flac_noseektable.ogg.2.dump | 2 +- .../ogg/bear_flac_noseektable.ogg.3.dump | 2 +- .../ogg/bear_flac_noseektable.ogg.unklen.dump | 2 +- .../util/FlacStreamMetadataTest.java | 2 +- 24 files changed, 39 insertions(+), 32 deletions(-) diff --git a/extensions/flac/src/androidTest/assets/bear.flac.0.dump b/extensions/flac/src/androidTest/assets/bear.flac.0.dump index 816356a1e6..d562052a4f 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.0.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: total output bytes = 526272 sample count = 33 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.1.dump b/extensions/flac/src/androidTest/assets/bear.flac.1.dump index 4a6b06725f..93f38227b8 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.1.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.1.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: total output bytes = 362432 sample count = 23 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.2.dump b/extensions/flac/src/androidTest/assets/bear.flac.2.dump index dddb6dc264..9c53a95b06 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.2.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.2.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: total output bytes = 182208 sample count = 12 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.3.dump b/extensions/flac/src/androidTest/assets/bear.flac.3.dump index 0dbe575ecf..82e23a21c1 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.3.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.3.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: total output bytes = 18368 sample count = 2 diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 7c69a93fc9..2c6f51da02 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -212,6 +212,7 @@ public final class FlacExtractor implements Extractor { input.getLength(), extractorOutput, outputFrameHolder); + @Nullable Metadata metadata = streamMetadata.getMetadataCopyWithAppendedEntriesFrom(id3Metadata); outputFormat(streamMetadata, metadata, trackOutput); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java index e86c9b0129..e66f39de8c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java @@ -81,7 +81,8 @@ public final class FlacMetadataReader { throws IOException, InterruptedException { @Nullable Id3Decoder.FramePredicate id3FramePredicate = parseData ? null : Id3Decoder.NO_FRAMES_PREDICATE; - return new Id3Peeker().peekId3Data(input, id3FramePredicate); + @Nullable Metadata id3Metadata = new Id3Peeker().peekId3Data(input, id3FramePredicate); + return id3Metadata == null || id3Metadata.length() == 0 ? null : id3Metadata; } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java index 2772f7e0c6..db1de183ac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/FlacStreamMetadata.java @@ -80,8 +80,8 @@ public final class FlacStreamMetadata { /** Total number of samples, or 0 if the value is unknown. */ public final long totalSamples; - /** Content metadata. */ - private final Metadata metadata; + /** Content metadata, or {@code null} if it is not provided. */ + @Nullable private final Metadata metadata; /** * Parses binary FLAC stream info metadata. @@ -102,7 +102,7 @@ public final class FlacStreamMetadata { bitsPerSample = scratch.readBits(5) + 1; bitsPerSampleLookupKey = getBitsPerSampleLookupKey(bitsPerSample); totalSamples = scratch.readBitsToLong(36); - metadata = new Metadata(); + metadata = null; } // Used in native code. @@ -138,7 +138,7 @@ public final class FlacStreamMetadata { int channels, int bitsPerSample, long totalSamples, - Metadata metadata) { + @Nullable Metadata metadata) { this.minBlockSizeSamples = minBlockSizeSamples; this.maxBlockSizeSamples = maxBlockSizeSamples; this.minFrameSize = minFrameSize; @@ -213,7 +213,7 @@ public final class FlacStreamMetadata { // Set the last metadata block flag, ignore the other blocks. streamMarkerAndInfoBlock[4] = (byte) 0x80; int maxInputSize = maxFrameSize > 0 ? maxFrameSize : Format.NO_VALUE; - Metadata metadataWithId3 = metadata.copyWithAppendedEntriesFrom(id3Metadata); + @Nullable Metadata metadataWithId3 = getMetadataCopyWithAppendedEntriesFrom(id3Metadata); return Format.createAudioSampleFormat( /* id= */ null, @@ -234,14 +234,16 @@ public final class FlacStreamMetadata { } /** Returns a copy of the content metadata with entries from {@code other} appended. */ + @Nullable public Metadata getMetadataCopyWithAppendedEntriesFrom(@Nullable Metadata other) { - return metadata.copyWithAppendedEntriesFrom(other); + return metadata == null ? other : metadata.copyWithAppendedEntriesFrom(other); } /** Returns a copy of {@code this} with the given Vorbis comments added to the metadata. */ public FlacStreamMetadata copyWithVorbisComments(List vorbisComments) { + @Nullable Metadata appendedMetadata = - metadata.copyWithAppendedEntriesFrom( + getMetadataCopyWithAppendedEntriesFrom( buildMetadata(vorbisComments, Collections.emptyList())); return new FlacStreamMetadata( minBlockSizeSamples, @@ -257,8 +259,10 @@ public final class FlacStreamMetadata { /** Returns a copy of {@code this} with the given picture frames added to the metadata. */ public FlacStreamMetadata copyWithPictureFrames(List pictureFrames) { + @Nullable Metadata appendedMetadata = - metadata.copyWithAppendedEntriesFrom(buildMetadata(Collections.emptyList(), pictureFrames)); + getMetadataCopyWithAppendedEntriesFrom( + buildMetadata(Collections.emptyList(), pictureFrames)); return new FlacStreamMetadata( minBlockSizeSamples, maxBlockSizeSamples, @@ -317,10 +321,11 @@ public final class FlacStreamMetadata { } } + @Nullable private static Metadata buildMetadata( List vorbisComments, List pictureFrames) { if (vorbisComments.isEmpty() && pictureFrames.isEmpty()) { - return new Metadata(); + return null; } ArrayList metadataEntries = new ArrayList<>(); @@ -336,6 +341,6 @@ public final class FlacStreamMetadata { } metadataEntries.addAll(pictureFrames); - return metadataEntries.isEmpty() ? new Metadata() : new Metadata(metadataEntries); + return metadataEntries.isEmpty() ? null : new Metadata(metadataEntries); } } diff --git a/library/core/src/test/assets/flac/bear.flac.0.dump b/library/core/src/test/assets/flac/bear.flac.0.dump index 109cc49ebb..bd8f1827e4 100644 --- a/library/core/src/test/assets/flac/bear.flac.0.dump +++ b/library/core/src/test/assets/flac/bear.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump index a7c8b628ba..4ef7138487 100644 --- a/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_no_min_max_frame_size.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 9218FDB7 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump b/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump index 7606154ddd..45b75392b3 100644 --- a/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_no_num_samples.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 49FA2C21 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump b/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump index 109cc49ebb..bd8f1827e4 100644 --- a/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_one_metadata_block.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump b/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump index 488517947c..e6caad8e84 100644 --- a/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_uncommon_sample_rate.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 7249A1B8 total output bytes = 144086 diff --git a/library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump b/library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump index 109cc49ebb..bd8f1827e4 100644 --- a/library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump +++ b/library/core/src/test/assets/flac/bear_with_id3_disabled.flac.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump index 896c8ad6c5..d32342619c 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump index e85b504a39..17e6c6d862 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.1.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 113666 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump index 63bc130424..e52b8897c8 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.2.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 55652 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump index fdebce7743..dabf5552da 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.3.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 445 diff --git a/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump b/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump index 896c8ad6c5..d32342619c 100644 --- a/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump +++ b/library/core/src/test/assets/ogg/bear_flac.ogg.unklen.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump index b09453f208..efbf8a3609 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.0.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump index 4ab08524ae..80ad2045ce 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.1.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 113666 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump index 3a846736d2..c2efd50a33 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.2.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 55652 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump index 5bf1a92472..26601231a6 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.3.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 445 diff --git a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump index 1a0686c5fd..67a1fecee4 100644 --- a/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump +++ b/library/core/src/test/assets/ogg/bear_flac_noseektable.ogg.unklen.dump @@ -24,7 +24,7 @@ track 0: selectionFlags = 0 language = null drmInitData = - - metadata = entries=[] + metadata = null initializationData: data = length 42, hash 83F6895 total output bytes = 164431 diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java index d3d3e53458..ddaa550b7f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/FlacStreamMetadataTest.java @@ -75,7 +75,7 @@ public final class FlacStreamMetadataTest { /* pictureFrames= */ new ArrayList<>()) .getMetadataCopyWithAppendedEntriesFrom(/* other= */ null); - assertThat(metadata.length()).isEqualTo(0); + assertThat(metadata).isNull(); } @Test From 24afcdc36bc43eec13905c6cc6f04e9b0a08af71 Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 9 Dec 2019 10:34:41 +0000 Subject: [PATCH 0044/1052] Remove TODO for supporting streams in Java FLAC extractor Flac streams exist but are not commonly used. Also, they are not supported by the FLAC extension extractor. PiperOrigin-RevId: 284514327 --- .../google/android/exoplayer2/extractor/flac/FlacExtractor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java index 0f67153e61..9c8136e9a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -43,7 +43,6 @@ import java.lang.annotation.RetentionPolicy; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // TODO: implement seeking. -// TODO: support live streams. /** * Extracts data from FLAC container format. * From ff1efd4ec2639a5558d904bd396ba8b6c26021ca Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 9 Dec 2019 13:57:21 +0000 Subject: [PATCH 0045/1052] Add peek() method to ExtractorInput PiperOrigin-RevId: 284537150 --- .../extractor/DefaultExtractorInput.java | 31 ++- .../exoplayer2/extractor/ExtractorInput.java | 44 ++-- .../extractor/DefaultExtractorInputTest.java | 226 +++++++++++++++--- .../testutil/FakeExtractorInput.java | 46 ++-- 4 files changed, 281 insertions(+), 66 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java index 450cca42b0..c6f1129da8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java @@ -58,7 +58,9 @@ public final class DefaultExtractorInput implements ExtractorInput { public int read(byte[] target, int offset, int length) throws IOException, InterruptedException { int bytesRead = readFromPeekBuffer(target, offset, length); if (bytesRead == 0) { - bytesRead = readFromDataSource(target, offset, length, 0, true); + bytesRead = + readFromDataSource( + target, offset, length, /* bytesAlreadyRead= */ 0, /* allowEndOfInput= */ true); } commitBytesRead(bytesRead); return bytesRead; @@ -110,6 +112,31 @@ public final class DefaultExtractorInput implements ExtractorInput { skipFully(length, false); } + @Override + public int peek(byte[] target, int offset, int length) throws IOException, InterruptedException { + ensureSpaceForPeek(length); + int peekBufferRemainingBytes = peekBufferLength - peekBufferPosition; + int bytesPeeked; + if (peekBufferRemainingBytes == 0) { + bytesPeeked = + readFromDataSource( + peekBuffer, + peekBufferPosition, + length, + /* bytesAlreadyRead= */ 0, + /* allowEndOfInput= */ true); + if (bytesPeeked == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + peekBufferLength += bytesPeeked; + } else { + bytesPeeked = Math.min(length, peekBufferRemainingBytes); + } + System.arraycopy(peekBuffer, peekBufferPosition, target, offset, bytesPeeked); + peekBufferPosition += bytesPeeked; + return bytesPeeked; + } + @Override public boolean peekFully(byte[] target, int offset, int length, boolean allowEndOfInput) throws IOException, InterruptedException { @@ -201,7 +228,7 @@ public final class DefaultExtractorInput implements ExtractorInput { } /** - * Reads from the peek buffer + * Reads from the peek buffer. * * @param target A target array into which data should be written. * @param offset The offset into the target array at which to write. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ExtractorInput.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ExtractorInput.java index 461b059bad..8e5d6f0448 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ExtractorInput.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ExtractorInput.java @@ -27,19 +27,19 @@ import java.io.InputStream; * for more info about each mode. * *

    * *

    {@link InputStream}-like methods

    * - *

    The {@code read()} and {@code skip()} methods provide {@link InputStream}-like byte-level - * access operations. The {@code length} parameter is a maximum, and each method returns the number - * of bytes actually processed. This may be less than {@code length} because the end of the input - * was reached, or the method was interrupted, or the operation was aborted early for another - * reason. + *

    The {@code read()/peek()} and {@code skip()} methods provide {@link InputStream}-like + * byte-level access operations. The {@code length} parameter is a maximum, and each method returns + * the number of bytes actually processed. This may be less than {@code length} because the end of + * the input was reached, or the method was interrupted, or the operation was aborted early for + * another reason. * *

    Block-based methods

    * @@ -102,7 +102,8 @@ public interface ExtractorInput { throws IOException, InterruptedException; /** - * Equivalent to {@code readFully(target, offset, length, false)}. + * Equivalent to {@link #readFully(byte[], int, int, boolean) readFully(target, offset, length, + * false)}. * * @param target A target array into which data should be written. * @param offset The offset into the target array at which to write. @@ -155,8 +156,11 @@ public interface ExtractorInput { void skipFully(int length) throws IOException, InterruptedException; /** - * Peeks {@code length} bytes from the peek position, writing them into {@code target} at index - * {@code offset}. The current read position is left unchanged. + * Peeks up to {@code length} bytes from the peek position. The current read position is left + * unchanged. + * + *

    This method blocks until at least one byte of data can be peeked, the end of the input is + * detected, or an exception is thrown. * *

    Calling {@link #resetPeekPosition()} resets the peek position to equal the current read * position, so the caller can peek the same data again. Reading or skipping also resets the peek @@ -164,6 +168,18 @@ public interface ExtractorInput { * * @param target A target array into which data should be written. * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to peek from the input. + * @return The number of bytes peeked, or {@link C#RESULT_END_OF_INPUT} if the input has ended. + * @throws IOException If an error occurs peeking from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + int peek(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Like {@link #peek(byte[], int, int)}, but peeks the requested {@code length} in full. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. * @param length The number of bytes to peek from the input. * @param allowEndOfInput True if encountering the end of the input having peeked no data is * allowed, and should result in {@code false} being returned. False if it should be @@ -181,12 +197,8 @@ public interface ExtractorInput { throws IOException, InterruptedException; /** - * Peeks {@code length} bytes from the peek position, writing them into {@code target} at index - * {@code offset}. The current read position is left unchanged. - *

    - * Calling {@link #resetPeekPosition()} resets the peek position to equal the current read - * position, so the caller can peek the same data again. Reading and skipping also reset the peek - * position. + * Equivalent to {@link #peekFully(byte[], int, int, boolean) peekFully(target, offset, length, + * false)}. * * @param target A target array into which data should be written. * @param offset The offset into the target array at which to write. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorInputTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorInputTest.java index 6dbec3ecf4..ccc806fe61 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorInputTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorInputTest.java @@ -49,7 +49,7 @@ public class DefaultExtractorInputTest { } @Test - public void testRead() throws Exception { + public void testReadMultipleTimes() throws Exception { DefaultExtractorInput input = createDefaultExtractorInput(); byte[] target = new byte[TEST_DATA.length]; // We expect to perform three reads of three bytes, as setup in buildTestDataSource. @@ -60,39 +60,70 @@ public class DefaultExtractorInputTest { assertThat(bytesRead).isEqualTo(6); bytesRead += input.read(target, 6, TEST_DATA.length); assertThat(bytesRead).isEqualTo(9); - // Check the read data is correct. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); - // Check we're now indicated that the end of input is reached. - int expectedEndOfInput = input.read(target, 0, TEST_DATA.length); - assertThat(expectedEndOfInput).isEqualTo(RESULT_END_OF_INPUT); + assertThat(input.getPosition()).isEqualTo(9); + assertThat(TEST_DATA).isEqualTo(target); } @Test - public void testReadPeeked() throws Exception { + public void testReadAlreadyPeeked() throws Exception { DefaultExtractorInput input = createDefaultExtractorInput(); byte[] target = new byte[TEST_DATA.length]; input.advancePeekPosition(TEST_DATA.length); + int bytesRead = input.read(target, 0, TEST_DATA.length - 1); + assertThat(bytesRead).isEqualTo(TEST_DATA.length - 1); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length - 1); + assertThat(Arrays.copyOf(TEST_DATA, TEST_DATA.length - 1)) + .isEqualTo(Arrays.copyOf(target, TEST_DATA.length - 1)); + } + + @Test + public void testReadPartiallyPeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + input.advancePeekPosition(TEST_DATA.length - 1); int bytesRead = input.read(target, 0, TEST_DATA.length); - assertThat(bytesRead).isEqualTo(TEST_DATA.length); - // Check the read data is correct. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); + assertThat(bytesRead).isEqualTo(TEST_DATA.length - 1); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length - 1); + assertThat(Arrays.copyOf(TEST_DATA, TEST_DATA.length - 1)) + .isEqualTo(Arrays.copyOf(target, TEST_DATA.length - 1)); } @Test - public void testReadMoreDataPeeked() throws Exception { + public void testReadEndOfInputBeforeFirstByteRead() throws Exception { DefaultExtractorInput input = createDefaultExtractorInput(); byte[] target = new byte[TEST_DATA.length]; - input.advancePeekPosition(TEST_DATA.length); + input.skipFully(TEST_DATA.length); + int bytesRead = input.read(target, 0, TEST_DATA.length); - int bytesRead = input.read(target, 0, TEST_DATA.length + 1); - assertThat(bytesRead).isEqualTo(TEST_DATA.length); + assertThat(bytesRead).isEqualTo(RESULT_END_OF_INPUT); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); + } - // Check the read data is correct. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); + @Test + public void testReadEndOfInputAfterFirstByteRead() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + input.skipFully(TEST_DATA.length - 1); + int bytesRead = input.read(target, 0, TEST_DATA.length); + + assertThat(bytesRead).isEqualTo(1); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); + } + + @Test + public void testReadZeroLength() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + int bytesRead = input.read(target, /* offset= */ 0, /* length= */ 0); + + assertThat(bytesRead).isEqualTo(0); } @Test @@ -101,7 +132,7 @@ public class DefaultExtractorInputTest { byte[] target = new byte[TEST_DATA.length]; input.readFully(target, 0, TEST_DATA.length); // Check that we read the whole of TEST_DATA. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); + assertThat(TEST_DATA).isEqualTo(target); assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); // Check that we see end of input if we read again with allowEndOfInput set. boolean result = input.readFully(target, 0, 1, true); @@ -121,11 +152,11 @@ public class DefaultExtractorInputTest { DefaultExtractorInput input = createDefaultExtractorInput(); byte[] target = new byte[5]; input.readFully(target, 0, 5); - assertThat(Arrays.equals(copyOf(TEST_DATA, 5), target)).isTrue(); + assertThat(copyOf(TEST_DATA, 5)).isEqualTo(target); assertThat(input.getPosition()).isEqualTo(5); target = new byte[4]; input.readFully(target, 0, 4); - assertThat(Arrays.equals(copyOfRange(TEST_DATA, 5, 9), target)).isTrue(); + assertThat(copyOfRange(TEST_DATA, 5, 9)).isEqualTo(target); assertThat(input.getPosition()).isEqualTo(5 + 4); } @@ -180,27 +211,23 @@ public class DefaultExtractorInputTest { input.readFully(target, 0, TEST_DATA.length); // Check the read data is correct. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); + assertThat(TEST_DATA).isEqualTo(target); assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); } @Test - public void testSkip() throws Exception { - FakeDataSource testDataSource = buildDataSource(); - DefaultExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET); + public void testSkipMultipleTimes() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); // We expect to perform three skips of three bytes, as setup in buildTestDataSource. for (int i = 0; i < 3; i++) { assertThat(input.skip(TEST_DATA.length)).isEqualTo(3); } - // Check we're now indicated that the end of input is reached. - int expectedEndOfInput = input.skip(TEST_DATA.length); - assertThat(expectedEndOfInput).isEqualTo(RESULT_END_OF_INPUT); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); } @Test public void testLargeSkip() throws Exception { - FakeDataSource testDataSource = buildLargeDataSource(); - DefaultExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET); + DefaultExtractorInput input = createDefaultExtractorInput(); // Check that skipping the entire data source succeeds. int bytesToSkip = LARGE_TEST_DATA_LENGTH; while (bytesToSkip > 0) { @@ -208,6 +235,59 @@ public class DefaultExtractorInputTest { } } + @Test + public void testSkipAlreadyPeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + + input.advancePeekPosition(TEST_DATA.length); + int bytesSkipped = input.skip(TEST_DATA.length - 1); + + assertThat(bytesSkipped).isEqualTo(TEST_DATA.length - 1); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length - 1); + } + + @Test + public void testSkipPartiallyPeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + + input.advancePeekPosition(TEST_DATA.length - 1); + int bytesSkipped = input.skip(TEST_DATA.length); + + assertThat(bytesSkipped).isEqualTo(TEST_DATA.length - 1); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length - 1); + } + + @Test + public void testSkipEndOfInputBeforeFirstByteSkipped() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + + input.skipFully(TEST_DATA.length); + int bytesSkipped = input.skip(TEST_DATA.length); + + assertThat(bytesSkipped).isEqualTo(RESULT_END_OF_INPUT); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); + } + + @Test + public void testSkipEndOfInputAfterFirstByteSkipped() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + + input.skipFully(TEST_DATA.length - 1); + int bytesSkipped = input.skip(TEST_DATA.length); + + assertThat(bytesSkipped).isEqualTo(1); + assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); + } + + @Test + public void testSkipZeroLength() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + + int bytesRead = input.skip(0); + + assertThat(bytesRead).isEqualTo(0); + } + @Test public void testSkipFullyOnce() throws Exception { // Skip TEST_DATA. @@ -309,6 +389,86 @@ public class DefaultExtractorInputTest { } } + @Test + public void testPeekMultipleTimes() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + // We expect to perform three peeks of three bytes, as setup in buildTestDataSource. + int bytesPeeked = 0; + bytesPeeked += input.peek(target, 0, TEST_DATA.length); + assertThat(bytesPeeked).isEqualTo(3); + bytesPeeked += input.peek(target, 3, TEST_DATA.length); + assertThat(bytesPeeked).isEqualTo(6); + bytesPeeked += input.peek(target, 6, TEST_DATA.length); + assertThat(bytesPeeked).isEqualTo(9); + assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); + assertThat(TEST_DATA).isEqualTo(target); + } + + @Test + public void testPeekAlreadyPeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + input.advancePeekPosition(TEST_DATA.length); + input.resetPeekPosition(); + int bytesPeeked = input.peek(target, 0, TEST_DATA.length - 1); + + assertThat(bytesPeeked).isEqualTo(TEST_DATA.length - 1); + assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length - 1); + assertThat(Arrays.copyOf(TEST_DATA, TEST_DATA.length - 1)) + .isEqualTo(Arrays.copyOf(target, TEST_DATA.length - 1)); + } + + @Test + public void testPeekPartiallyPeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + input.advancePeekPosition(TEST_DATA.length - 1); + input.resetPeekPosition(); + int bytesPeeked = input.peek(target, 0, TEST_DATA.length); + + assertThat(bytesPeeked).isEqualTo(TEST_DATA.length - 1); + assertThat(Arrays.copyOf(TEST_DATA, TEST_DATA.length - 1)) + .isEqualTo(Arrays.copyOf(target, TEST_DATA.length - 1)); + } + + @Test + public void testPeekEndOfInputBeforeFirstBytePeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + input.advancePeekPosition(TEST_DATA.length); + int bytesPeeked = input.peek(target, 0, TEST_DATA.length); + + assertThat(bytesPeeked).isEqualTo(RESULT_END_OF_INPUT); + assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); + } + + @Test + public void testPeekEndOfInputAfterFirstBytePeeked() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + input.advancePeekPosition(TEST_DATA.length - 1); + int bytesPeeked = input.peek(target, 0, TEST_DATA.length); + + assertThat(bytesPeeked).isEqualTo(1); + assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); + } + + @Test + public void testPeekZeroLength() throws Exception { + DefaultExtractorInput input = createDefaultExtractorInput(); + byte[] target = new byte[TEST_DATA.length]; + + int bytesPeeked = input.peek(target, /* offset= */ 0, /* length= */ 0); + + assertThat(bytesPeeked).isEqualTo(0); + } + @Test public void testPeekFully() throws Exception { DefaultExtractorInput input = createDefaultExtractorInput(); @@ -316,14 +476,14 @@ public class DefaultExtractorInputTest { input.peekFully(target, 0, TEST_DATA.length); // Check that we read the whole of TEST_DATA. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); + assertThat(TEST_DATA).isEqualTo(target); assertThat(input.getPosition()).isEqualTo(0); assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); // Check that we can read again from the buffer byte[] target2 = new byte[TEST_DATA.length]; input.readFully(target2, 0, TEST_DATA.length); - assertThat(Arrays.equals(TEST_DATA, target2)).isTrue(); + assertThat(TEST_DATA).isEqualTo(target2); assertThat(input.getPosition()).isEqualTo(TEST_DATA.length); assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); @@ -350,7 +510,7 @@ public class DefaultExtractorInputTest { input.peekFully(target, /* offset= */ 0, /* length= */ TEST_DATA.length); assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); - assertThat(Arrays.equals(TEST_DATA, Arrays.copyOf(target, TEST_DATA.length))).isTrue(); + assertThat(TEST_DATA).isEqualTo(Arrays.copyOf(target, TEST_DATA.length)); } @Test @@ -360,14 +520,14 @@ public class DefaultExtractorInputTest { input.peekFully(target, 0, TEST_DATA.length); // Check that we read the whole of TEST_DATA. - assertThat(Arrays.equals(TEST_DATA, target)).isTrue(); + assertThat(TEST_DATA).isEqualTo(target); assertThat(input.getPosition()).isEqualTo(0); // Check that we can peek again after resetting. input.resetPeekPosition(); byte[] target2 = new byte[TEST_DATA.length]; input.peekFully(target2, 0, TEST_DATA.length); - assertThat(Arrays.equals(TEST_DATA, target2)).isTrue(); + assertThat(TEST_DATA).isEqualTo(target2); // Check that we fail with EOFException if we peek past the end of the input. try { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java index 443ffdb12c..7323cfd0fe 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java @@ -65,7 +65,8 @@ public final class FakeExtractorInput implements ExtractorInput { private int readPosition; private int peekPosition; - private final SparseBooleanArray partiallySatisfiedTargetPositions; + private final SparseBooleanArray partiallySatisfiedTargetReadPositions; + private final SparseBooleanArray partiallySatisfiedTargetPeekPositions; private final SparseBooleanArray failedReadPositions; private final SparseBooleanArray failedPeekPositions; @@ -75,7 +76,8 @@ public final class FakeExtractorInput implements ExtractorInput { this.simulateUnknownLength = simulateUnknownLength; this.simulatePartialReads = simulatePartialReads; this.simulateIOErrors = simulateIOErrors; - partiallySatisfiedTargetPositions = new SparseBooleanArray(); + partiallySatisfiedTargetReadPositions = new SparseBooleanArray(); + partiallySatisfiedTargetPeekPositions = new SparseBooleanArray(); failedReadPositions = new SparseBooleanArray(); failedPeekPositions = new SparseBooleanArray(); } @@ -84,7 +86,8 @@ public final class FakeExtractorInput implements ExtractorInput { public void reset() { readPosition = 0; peekPosition = 0; - partiallySatisfiedTargetPositions.clear(); + partiallySatisfiedTargetReadPositions.clear(); + partiallySatisfiedTargetPeekPositions.clear(); failedReadPositions.clear(); failedPeekPositions.clear(); } @@ -104,7 +107,7 @@ public final class FakeExtractorInput implements ExtractorInput { @Override public int read(byte[] target, int offset, int length) throws IOException { checkIOException(readPosition, failedReadPositions); - length = getReadLength(length); + length = getLengthToRead(readPosition, length, partiallySatisfiedTargetReadPositions); return readFullyInternal(target, offset, length, true) ? length : C.RESULT_END_OF_INPUT; } @@ -123,7 +126,7 @@ public final class FakeExtractorInput implements ExtractorInput { @Override public int skip(int length) throws IOException { checkIOException(readPosition, failedReadPositions); - length = getReadLength(length); + length = getLengthToRead(readPosition, length, partiallySatisfiedTargetReadPositions); return skipFullyInternal(length, true) ? length : C.RESULT_END_OF_INPUT; } @@ -138,16 +141,18 @@ public final class FakeExtractorInput implements ExtractorInput { skipFully(length, false); } + @Override + public int peek(byte[] target, int offset, int length) throws IOException { + checkIOException(peekPosition, failedPeekPositions); + length = getLengthToRead(peekPosition, length, partiallySatisfiedTargetPeekPositions); + return peekFullyInternal(target, offset, length, true) ? length : C.RESULT_END_OF_INPUT; + } + @Override public boolean peekFully(byte[] target, int offset, int length, boolean allowEndOfInput) throws IOException { checkIOException(peekPosition, failedPeekPositions); - if (!checkXFully(allowEndOfInput, peekPosition, length)) { - return false; - } - System.arraycopy(data, peekPosition, target, offset, length); - peekPosition += length; - return true; + return peekFullyInternal(target, offset, length, allowEndOfInput); } @Override @@ -221,18 +226,19 @@ public final class FakeExtractorInput implements ExtractorInput { return true; } - private int getReadLength(int requestedLength) { - if (readPosition == data.length) { + private int getLengthToRead( + int position, int requestedLength, SparseBooleanArray partiallySatisfiedTargetPositions) { + if (position == data.length) { // If the requested length is non-zero, the end of the input will be read. return requestedLength == 0 ? 0 : Integer.MAX_VALUE; } - int targetPosition = readPosition + requestedLength; + int targetPosition = position + requestedLength; if (simulatePartialReads && requestedLength > 1 && !partiallySatisfiedTargetPositions.get(targetPosition)) { partiallySatisfiedTargetPositions.put(targetPosition, true); return 1; } - return Math.min(requestedLength, data.length - readPosition); + return Math.min(requestedLength, data.length - position); } private boolean readFullyInternal(byte[] target, int offset, int length, boolean allowEndOfInput) @@ -255,6 +261,16 @@ public final class FakeExtractorInput implements ExtractorInput { return true; } + private boolean peekFullyInternal(byte[] target, int offset, int length, boolean allowEndOfInput) + throws EOFException { + if (!checkXFully(allowEndOfInput, peekPosition, length)) { + return false; + } + System.arraycopy(data, peekPosition, target, offset, length); + peekPosition += length; + return true; + } + /** * Builder of {@link FakeExtractorInput} instances. */ From 4ed512d611ba51daba7ced19b38cac5946d008d3 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 10 Dec 2019 16:42:33 +0000 Subject: [PATCH 0046/1052] Fix javadoc warnings exposed by -Xdoclint PiperOrigin-RevId: 284776943 --- .../google/android/exoplayer2/ExoPlayer.java | 4 +- .../google/android/exoplayer2/Renderer.java | 3 +- .../google/android/exoplayer2/Timeline.java | 50 +++++++++---------- .../trackselection/TrackSelector.java | 2 - 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 7c8a454191..c2e5c7170f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -93,8 +93,8 @@ import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; * *

    The figure below shows ExoPlayer's threading model. * - *

    ExoPlayer's threading
- * model + *

    ExoPlayer's
+ * threading model * *

    */ public final class DefaultExtractorsFactory implements ExtractorsFactory { @@ -247,10 +253,6 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING : 0)); extractors[12] = new Ac4Extractor(); - // Prefer the FLAC extension extractor because it outputs raw audio, which can be handled by the - // framework on all API levels, unlike the core library FLAC extractor, which outputs FLAC audio - // frames and so relies on having a FLAC decoder (e.g., a MediaCodec decoder that handles FLAC - // (from API 27), or the FFmpeg extension with FLAC enabled). if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { try { extractors[13] = FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(); From d93b57c0099a8231ac658006830044bd65857d2a Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 17 Feb 2020 14:23:06 +0000 Subject: [PATCH 0139/1052] Zero out trailing bytes in CryptoInfo.iv CryptoInfo.iv length is always 16. When the actual initialization vector is shorter, zero out the trailing bytes. Issue: #6982 PiperOrigin-RevId: 295575845 --- RELEASENOTES.md | 6 ++ .../exoplayer2/decoder/CryptoInfo.java | 18 +++- .../exoplayer2/source/SampleDataQueue.java | 20 +++-- .../exoplayer2/source/SampleQueueTest.java | 87 +++++++++++++++---- 4 files changed, 107 insertions(+), 24 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8a72373fe9..6acd772e05 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,11 @@ # Release notes # +### 2.11.3 (2020-02-19) ### + +* DRM: Fix issue switching from protected content that uses a 16-byte + initialization vector to one that uses an 8-byte initialization vector + ([#6982](https://github.com/google/ExoPlayer/issues/6982)). + ### 2.11.2 (2020-02-13) ### * Add Java FLAC extractor diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java index 379ca971b5..b865d5bb6f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java @@ -25,27 +25,41 @@ import com.google.android.exoplayer2.util.Util; public final class CryptoInfo { /** + * The 16 byte initialization vector. If the initialization vector of the content is shorter than + * 16 bytes, 0 byte padding is appended to extend the vector to the required 16 byte length. + * * @see android.media.MediaCodec.CryptoInfo#iv */ public byte[] iv; /** + * The 16 byte key id. + * * @see android.media.MediaCodec.CryptoInfo#key */ public byte[] key; /** + * The type of encryption that has been applied. Must be one of the {@link C.CryptoMode} values. + * * @see android.media.MediaCodec.CryptoInfo#mode */ - @C.CryptoMode - public int mode; + @C.CryptoMode public int mode; /** + * The number of leading unencrypted bytes in each sub-sample. If null, all bytes are treated as + * encrypted and {@link #numBytesOfEncryptedData} must be specified. + * * @see android.media.MediaCodec.CryptoInfo#numBytesOfClearData */ public int[] numBytesOfClearData; /** + * The number of trailing encrypted bytes in each sub-sample. If null, all bytes are treated as + * clear and {@link #numBytesOfClearData} must be specified. + * * @see android.media.MediaCodec.CryptoInfo#numBytesOfEncryptedData */ public int[] numBytesOfEncryptedData; /** + * The number of subSamples that make up the buffer's contents. + * * @see android.media.MediaCodec.CryptoInfo#numSubSamples */ public int numSubSamples; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java index 68761cef19..3779fe33e5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.TrackOutput.CryptoData; @@ -27,6 +28,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; import java.nio.ByteBuffer; +import java.util.Arrays; /** A queue of media sample data. */ /* package */ class SampleDataQueue { @@ -228,10 +230,14 @@ import java.nio.ByteBuffer; int ivSize = signalByte & 0x7F; // Read the initialization vector. - if (buffer.cryptoInfo.iv == null) { - buffer.cryptoInfo.iv = new byte[16]; + CryptoInfo cryptoInfo = buffer.cryptoInfo; + if (cryptoInfo.iv == null) { + cryptoInfo.iv = new byte[16]; + } else { + // Zero out cryptoInfo.iv so that if ivSize < 16, the remaining bytes are correctly set to 0. + Arrays.fill(cryptoInfo.iv, (byte) 0); } - readData(offset, buffer.cryptoInfo.iv, ivSize); + readData(offset, cryptoInfo.iv, ivSize); offset += ivSize; // Read the subsample count, if present. @@ -246,11 +252,11 @@ import java.nio.ByteBuffer; } // Write the clear and encrypted subsample sizes. - int[] clearDataSizes = buffer.cryptoInfo.numBytesOfClearData; + @Nullable int[] clearDataSizes = cryptoInfo.numBytesOfClearData; if (clearDataSizes == null || clearDataSizes.length < subsampleCount) { clearDataSizes = new int[subsampleCount]; } - int[] encryptedDataSizes = buffer.cryptoInfo.numBytesOfEncryptedData; + @Nullable int[] encryptedDataSizes = cryptoInfo.numBytesOfEncryptedData; if (encryptedDataSizes == null || encryptedDataSizes.length < subsampleCount) { encryptedDataSizes = new int[subsampleCount]; } @@ -271,12 +277,12 @@ import java.nio.ByteBuffer; // Populate the cryptoInfo. CryptoData cryptoData = extrasHolder.cryptoData; - buffer.cryptoInfo.set( + cryptoInfo.set( subsampleCount, clearDataSizes, encryptedDataSizes, cryptoData.encryptionKey, - buffer.cryptoInfo.iv, + cryptoInfo.iv, cryptoData.cryptoMode, cryptoData.encryptedBlocks, cryptoData.clearBlocks); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 583bdcb7be..427e81d29f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.C.BUFFER_FLAG_ENCRYPTED; import static com.google.android.exoplayer2.C.BUFFER_FLAG_KEY_FRAME; import static com.google.android.exoplayer2.C.RESULT_BUFFER_READ; import static com.google.android.exoplayer2.C.RESULT_FORMAT_READ; @@ -22,6 +23,7 @@ import static com.google.android.exoplayer2.C.RESULT_NOTHING_READ; import static com.google.common.truth.Truth.assertThat; import static java.lang.Long.MIN_VALUE; import static java.util.Arrays.copyOfRange; +import static org.junit.Assert.assertArrayEquals; import static org.mockito.Mockito.when; import androidx.annotation.Nullable; @@ -114,17 +116,13 @@ public final class SampleQueueTest { C.BUFFER_FLAG_KEY_FRAME, C.BUFFER_FLAG_ENCRYPTED, 0, C.BUFFER_FLAG_ENCRYPTED, }; private static final long[] ENCRYPTED_SAMPLE_TIMESTAMPS = new long[] {0, 1000, 2000, 3000}; - private static final Format[] ENCRYPTED_SAMPLES_FORMATS = + private static final Format[] ENCRYPTED_SAMPLE_FORMATS = new Format[] {FORMAT_ENCRYPTED, FORMAT_ENCRYPTED, FORMAT_1, FORMAT_ENCRYPTED}; /** Encrypted samples require the encryption preamble. */ - private static final int[] ENCRYPTED_SAMPLES_SIZES = new int[] {1, 3, 1, 3}; + private static final int[] ENCRYPTED_SAMPLE_SIZES = new int[] {1, 3, 1, 3}; - private static final int[] ENCRYPTED_SAMPLES_OFFSETS = new int[] {7, 4, 3, 0}; - private static final byte[] ENCRYPTED_SAMPLES_DATA = new byte[8]; - - static { - Arrays.fill(ENCRYPTED_SAMPLES_DATA, (byte) 1); - } + private static final int[] ENCRYPTED_SAMPLE_OFFSETS = new int[] {7, 4, 3, 0}; + private static final byte[] ENCRYPTED_SAMPLE_DATA = new byte[] {1, 1, 1, 1, 1, 1, 1, 1}; private static final TrackOutput.CryptoData DUMMY_CRYPTO_DATA = new TrackOutput.CryptoData(C.CRYPTO_MODE_AES_CTR, new byte[16], 0, 0); @@ -461,6 +459,60 @@ public final class SampleQueueTest { /* decodeOnlyUntilUs= */ 0); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); + assertReadEncryptedSample(/* sampleIndex= */ 3); + } + + @Test + @SuppressWarnings("unchecked") + public void testTrailingCryptoInfoInitializationVectorBytesZeroed() { + when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); + DrmSession mockPlaceholderDrmSession = + (DrmSession) Mockito.mock(DrmSession.class); + when(mockPlaceholderDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); + when(mockDrmSessionManager.acquirePlaceholderSession( + ArgumentMatchers.any(), ArgumentMatchers.anyInt())) + .thenReturn(mockPlaceholderDrmSession); + + writeFormat(ENCRYPTED_SAMPLE_FORMATS[0]); + byte[] sampleData = new byte[] {0, 1, 2}; + byte[] initializationVector = new byte[] {7, 6, 5, 4, 3, 2, 1, 0}; + byte[] encryptedSampleData = + TestUtil.joinByteArrays( + new byte[] { + 0x08, // subsampleEncryption = false (1 bit), ivSize = 8 (7 bits). + }, + initializationVector, + sampleData); + writeSample( + encryptedSampleData, /* timestampUs= */ 0, BUFFER_FLAG_KEY_FRAME | BUFFER_FLAG_ENCRYPTED); + + int result = + sampleQueue.read( + formatHolder, + inputBuffer, + /* formatRequired= */ false, + /* loadingFinished= */ false, + /* decodeOnlyUntilUs= */ 0); + assertThat(result).isEqualTo(RESULT_FORMAT_READ); + + // Fill cryptoInfo.iv with non-zero data. When the 8 byte initialization vector is written into + // it, we expect the trailing 8 bytes to be zeroed. + inputBuffer.cryptoInfo.iv = new byte[16]; + Arrays.fill(inputBuffer.cryptoInfo.iv, (byte) 1); + + result = + sampleQueue.read( + formatHolder, + inputBuffer, + /* formatRequired= */ false, + /* loadingFinished= */ false, + /* decodeOnlyUntilUs= */ 0); + assertThat(result).isEqualTo(RESULT_BUFFER_READ); + + // Assert cryptoInfo.iv contains the 8-byte initialization vector and that the trailing 8 bytes + // have been zeroed. + byte[] expectedInitializationVector = Arrays.copyOf(initializationVector, 16); + assertArrayEquals(expectedInitializationVector, inputBuffer.cryptoInfo.iv); } @Test @@ -995,11 +1047,11 @@ public final class SampleQueueTest { private void writeTestDataWithEncryptedSections() { writeTestData( - ENCRYPTED_SAMPLES_DATA, - ENCRYPTED_SAMPLES_SIZES, - ENCRYPTED_SAMPLES_OFFSETS, + ENCRYPTED_SAMPLE_DATA, + ENCRYPTED_SAMPLE_SIZES, + ENCRYPTED_SAMPLE_OFFSETS, ENCRYPTED_SAMPLE_TIMESTAMPS, - ENCRYPTED_SAMPLES_FORMATS, + ENCRYPTED_SAMPLE_FORMATS, ENCRYPTED_SAMPLES_FLAGS); } @@ -1033,7 +1085,12 @@ public final class SampleQueueTest { /** Writes a single sample to {@code sampleQueue}. */ private void writeSample(byte[] data, long timestampUs, int sampleFlags) { sampleQueue.sampleData(new ParsableByteArray(data), data.length); - sampleQueue.sampleMetadata(timestampUs, sampleFlags, data.length, 0, null); + sampleQueue.sampleMetadata( + timestampUs, + sampleFlags, + data.length, + /* offset= */ 0, + (sampleFlags & C.BUFFER_FLAG_ENCRYPTED) != 0 ? DUMMY_CRYPTO_DATA : null); } /** @@ -1206,7 +1263,7 @@ public final class SampleQueueTest { } private void assertReadEncryptedSample(int sampleIndex) { - byte[] sampleData = new byte[ENCRYPTED_SAMPLES_SIZES[sampleIndex]]; + byte[] sampleData = new byte[ENCRYPTED_SAMPLE_SIZES[sampleIndex]]; Arrays.fill(sampleData, (byte) 1); boolean isKeyFrame = (ENCRYPTED_SAMPLES_FLAGS[sampleIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0; boolean isEncrypted = (ENCRYPTED_SAMPLES_FLAGS[sampleIndex] & C.BUFFER_FLAG_ENCRYPTED) != 0; @@ -1216,7 +1273,7 @@ public final class SampleQueueTest { isEncrypted, sampleData, /* offset= */ 0, - ENCRYPTED_SAMPLES_SIZES[sampleIndex] - (isEncrypted ? 2 : 0)); + ENCRYPTED_SAMPLE_SIZES[sampleIndex] - (isEncrypted ? 2 : 0)); } /** From 5f1c6b650d12ba7c83a20c8d18b25ba7bccc3dc8 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 17 Feb 2020 14:55:45 +0000 Subject: [PATCH 0140/1052] Fix SmoothStreaming Issue: #6981 PiperOrigin-RevId: 295579872 --- RELEASENOTES.md | 2 ++ .../source/smoothstreaming/DefaultSsChunkSource.java | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 6acd772e05..e23bf592a5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,8 @@ ### 2.11.3 (2020-02-19) ### +* SmoothStreaming: Fix regression that broke playback in 2.11.2 + ([#6981](https://github.com/google/ExoPlayer/issues/6981)). * DRM: Fix issue switching from protected content that uses a 16-byte initialization vector to one that uses an 8-byte initialization vector ([#6982](https://github.com/google/ExoPlayer/issues/6982)). diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java index 22dfb04f13..d005dac8da 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java @@ -113,9 +113,12 @@ public class DefaultSsChunkSource implements SsChunkSource { Track track = new Track(manifestTrackIndex, streamElement.type, streamElement.timescale, C.TIME_UNSET, manifest.durationUs, format, Track.TRANSFORMATION_NONE, trackEncryptionBoxes, nalUnitLengthFieldLength, null, null); - FragmentedMp4Extractor extractor = new FragmentedMp4Extractor( - FragmentedMp4Extractor.FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME - | FragmentedMp4Extractor.FLAG_WORKAROUND_IGNORE_TFDT_BOX, null, track, null); + FragmentedMp4Extractor extractor = + new FragmentedMp4Extractor( + FragmentedMp4Extractor.FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME + | FragmentedMp4Extractor.FLAG_WORKAROUND_IGNORE_TFDT_BOX, + /* timestampAdjuster= */ null, + track); extractorWrappers[i] = new ChunkExtractorWrapper(extractor, streamElement.type, format); } } From b14d8799470792c2c94f1c70c9fa244c7d353915 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Tue, 18 Feb 2020 14:47:58 +0000 Subject: [PATCH 0141/1052] Tweak Javadoc --- .../exoplayer2/extractor/DefaultExtractorsFactory.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index f52dc3defc..804f69f0f9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -53,10 +53,10 @@ import java.lang.reflect.Constructor; *

    The read position of {@code input} is left unchanged and the peek position is aligned with + * the read position. + * + * @param input Input stream to get the metadata from (starting from the read position). + * @return A {@link FirstFrameMetadata} containing the frame start marker (which should be the + * same for all the frames in the stream) and the block size of the frame. + * @throws ParserException If an error occurs parsing the frame metadata. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + */ + public static FirstFrameMetadata getFirstFrameMetadata(ExtractorInput input) + throws IOException, InterruptedException { + input.resetPeekPosition(); + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); + input.peekFully(scratch.data, 0, FlacConstants.MAX_FRAME_HEADER_SIZE); + + int frameStartMarker = scratch.readUnsignedShort(); + int syncCode = frameStartMarker >> 2; + if (syncCode != SYNC_CODE) { + input.resetPeekPosition(); + throw new ParserException("First frame does not start with sync code."); + } + + scratch.setPosition(0); + int firstFrameBlockSizeSamples = FlacFrameReader.getFrameBlockSizeSamples(scratch); + + input.resetPeekPosition(); + return new FirstFrameMetadata(frameStartMarker, firstFrameBlockSizeSamples); + } + + private static FlacStreamMetadata readStreamInfoBlock(ExtractorInput input) + throws IOException, InterruptedException { + byte[] scratchData = new byte[FlacConstants.STREAM_INFO_BLOCK_SIZE]; + input.readFully(scratchData, 0, FlacConstants.STREAM_INFO_BLOCK_SIZE); + return new FlacStreamMetadata( + scratchData, /* offset= */ FlacConstants.METADATA_BLOCK_HEADER_SIZE); + } + + private static List readVorbisCommentMetadataBlock(ExtractorInput input, int length) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(length); + input.readFully(scratch.data, 0, length); + scratch.skipBytes(FlacConstants.METADATA_BLOCK_HEADER_SIZE); + CommentHeader commentHeader = + VorbisUtil.readVorbisCommentHeader( + scratch, /* hasMetadataHeader= */ false, /* hasFramingBit= */ false); + return Arrays.asList(commentHeader.comments); + } + + private static PictureFrame readPictureMetadataBlock(ExtractorInput input, int length) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(length); + input.readFully(scratch.data, 0, length); + scratch.skipBytes(FlacConstants.METADATA_BLOCK_HEADER_SIZE); + + int pictureType = scratch.readInt(); + int mimeTypeLength = scratch.readInt(); + String mimeType = scratch.readString(mimeTypeLength, Charset.forName(C.ASCII_NAME)); + int descriptionLength = scratch.readInt(); + String description = scratch.readString(descriptionLength); + int width = scratch.readInt(); + int height = scratch.readInt(); + int depth = scratch.readInt(); + int colors = scratch.readInt(); + int pictureDataLength = scratch.readInt(); + byte[] pictureData = new byte[pictureDataLength]; + scratch.readBytes(pictureData, 0, pictureDataLength); + + return new PictureFrame( + pictureType, mimeType, description, width, height, depth, colors, pictureData); + } + + private FlacMetadataReader() {} +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java similarity index 96% rename from library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java rename to library/core/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java index 958a2ef955..b498be4a33 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.extractor.ogg; +package com.google.android.exoplayer2.extractor; import com.google.android.exoplayer2.util.Assertions; /** - * Wraps a byte array, providing methods that allow it to be read as a vorbis bitstream. + * Wraps a byte array, providing methods that allow it to be read as a Vorbis bitstream. * * @see Vorbis bitpacking * specification */ -/* package */ final class VorbisBitArray { +public final class VorbisBitArray { private final byte[] data; private final int byteLimit; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java similarity index 85% rename from library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisUtil.java rename to library/core/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java index eb4aee87a3..5066c3a7bd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java @@ -13,17 +13,87 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.extractor.ogg; +package com.google.android.exoplayer2.extractor; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableByteArray; import java.util.Arrays; -/** - * Utility methods for parsing vorbis streams. - */ -/* package */ final class VorbisUtil { +/** Utility methods for parsing Vorbis streams. */ +public final class VorbisUtil { + + /** Vorbis comment header. */ + public static final class CommentHeader { + + public final String vendor; + public final String[] comments; + public final int length; + + public CommentHeader(String vendor, String[] comments, int length) { + this.vendor = vendor; + this.comments = comments; + this.length = length; + } + } + + /** Vorbis identification header. */ + public static final class VorbisIdHeader { + + public final long version; + public final int channels; + public final long sampleRate; + public final int bitrateMax; + public final int bitrateNominal; + public final int bitrateMin; + public final int blockSize0; + public final int blockSize1; + public final boolean framingFlag; + public final byte[] data; + + public VorbisIdHeader( + long version, + int channels, + long sampleRate, + int bitrateMax, + int bitrateNominal, + int bitrateMin, + int blockSize0, + int blockSize1, + boolean framingFlag, + byte[] data) { + this.version = version; + this.channels = channels; + this.sampleRate = sampleRate; + this.bitrateMax = bitrateMax; + this.bitrateNominal = bitrateNominal; + this.bitrateMin = bitrateMin; + this.blockSize0 = blockSize0; + this.blockSize1 = blockSize1; + this.framingFlag = framingFlag; + this.data = data; + } + + public int getApproximateBitrate() { + return bitrateNominal == 0 ? (bitrateMin + bitrateMax) / 2 : bitrateNominal; + } + } + + /** Vorbis setup header modes. */ + public static final class Mode { + + public final boolean blockFlag; + public final int windowType; + public final int transformType; + public final int mapping; + + public Mode(boolean blockFlag, int windowType, int transformType, int mapping) { + this.blockFlag = blockFlag; + this.windowType = windowType; + this.transformType = transformType; + this.mapping = mapping; + } + } private static final String TAG = "VorbisUtil"; @@ -45,7 +115,7 @@ import java.util.Arrays; } /** - * Reads a vorbis identification header from {@code headerData}. + * Reads a Vorbis identification header from {@code headerData}. * * @see Vorbis * spec/Identification header @@ -70,7 +140,7 @@ import java.util.Arrays; int blockSize1 = (int) Math.pow(2, (blockSize & 0xF0) >> 4); boolean framingFlag = (headerData.readUnsignedByte() & 0x01) > 0; - // raw data of vorbis setup header has to be passed to decoder as CSD buffer #1 + // raw data of Vorbis setup header has to be passed to decoder as CSD buffer #1 byte[] data = Arrays.copyOf(headerData.data, headerData.limit()); return new VorbisIdHeader(version, channels, sampleRate, bitrateMax, bitrateNominal, bitrateMin, @@ -78,18 +148,41 @@ import java.util.Arrays; } /** - * Reads a vorbis comment header. + * Reads a Vorbis comment header. * - * @see - * Vorbis spec/Comment header - * @param headerData a {@link ParsableByteArray} wrapping the header data. - * @return a {@link VorbisUtil.CommentHeader} with all the comments. - * @throws ParserException thrown if invalid capture pattern is detected. + * @see Vorbis + * spec/Comment header + * @param headerData A {@link ParsableByteArray} wrapping the header data. + * @return A {@link VorbisUtil.CommentHeader} with all the comments. + * @throws ParserException If an error occurs parsing the comment header. */ public static CommentHeader readVorbisCommentHeader(ParsableByteArray headerData) throws ParserException { + return readVorbisCommentHeader( + headerData, /* hasMetadataHeader= */ true, /* hasFramingBit= */ true); + } - verifyVorbisHeaderCapturePattern(0x03, headerData, false); + /** + * Reads a Vorbis comment header. + * + *

  • AMR ({@link AmrExtractor}) *
  • FLAC *
      - *
    • if available, using the Flac extension extractor, - *
    • otherwise, using core's {@link FlacExtractor}. NOTE: Android devices do not generally - * include a FLAC decoder before API 27, which can be worked around by using the FLAC - * extension of the FFMPEG extension. + *
    • If available, the FLAC extension extractor is used. + *
    • Otherwise, the core {@link FlacExtractor} is used. Note that Android devices do not + * generally include a FLAC decoder before API 27. This can be worked around by using + * the FLAC extension or the FFmpeg extension. *
    * */ From f288b3e6a8ce72ecf7f18178fa23d08493e8c458 Mon Sep 17 00:00:00 2001 From: "Fillmore, Christopher" Date: Wed, 15 Jan 2020 12:19:59 -0500 Subject: [PATCH 0142/1052] Read response body in CronetDataSource for error response Issue #6853 --- .../ext/cronet/CronetDataSource.java | 131 ++++++++++-------- .../exoplayer2/upstream/HttpDataSource.java | 4 +- 2 files changed, 76 insertions(+), 59 deletions(-) diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java index 1903e33995..85752d19ed 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -443,16 +443,42 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { throw new OpenException(new InterruptedIOException(e), dataSpec, Status.INVALID); } + // Calculate the content length. + if (!isCompressed(responseInfo)) { + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + bytesRemaining = getContentLength(responseInfo); + } + } else { + // If the response is compressed then the content length will be that of the compressed data + // which isn't what we want. Always use the dataSpec length in this case. + bytesRemaining = dataSpec.length; + } + // Check for a valid response code. UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo); int responseCode = responseInfo.getHttpStatusCode(); if (responseCode < 200 || responseCode > 299) { + byte[] responseBody = null; + + if (bytesRemaining > 0) { + int responseBodyLength = (int)bytesRemaining; + responseBody = new byte[responseBodyLength]; + try { + readIntoByteArray(responseBody, 0, responseBodyLength); + } catch (HttpDataSourceException e) { + throw new OpenException(e, dataSpec, Status.INVALID); + } + } + InvalidResponseCodeException exception = new InvalidResponseCodeException( responseCode, responseInfo.getHttpStatusText(), responseInfo.getAllHeaders(), - dataSpec); + dataSpec, + responseBody); if (responseCode == 416) { exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); } @@ -474,19 +500,6 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { // requested position. bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; - // Calculate the content length. - if (!isCompressed(responseInfo)) { - if (dataSpec.length != C.LENGTH_UNSET) { - bytesRemaining = dataSpec.length; - } else { - bytesRemaining = getContentLength(responseInfo); - } - } else { - // If the response is compressed then the content length will be that of the compressed data - // which isn't what we want. Always use the dataSpec length in this case. - bytesRemaining = dataSpec.length; - } - opened = true; transferStarted(dataSpec); @@ -497,47 +510,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { Assertions.checkState(opened); - if (readLength == 0) { - return 0; - } else if (bytesRemaining == 0) { - return C.RESULT_END_OF_INPUT; - } - - ByteBuffer readBuffer = this.readBuffer; - if (readBuffer == null) { - readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); - readBuffer.limit(0); - this.readBuffer = readBuffer; - } - while (!readBuffer.hasRemaining()) { - // Fill readBuffer with more data from Cronet. - operation.close(); - readBuffer.clear(); - readInternal(castNonNull(readBuffer)); - - if (finished) { - bytesRemaining = 0; - return C.RESULT_END_OF_INPUT; - } else { - // The operation didn't time out, fail or finish, and therefore data must have been read. - readBuffer.flip(); - Assertions.checkState(readBuffer.hasRemaining()); - if (bytesToSkip > 0) { - int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip); - readBuffer.position(readBuffer.position() + bytesSkipped); - bytesToSkip -= bytesSkipped; - } - } - } - - int bytesRead = Math.min(readBuffer.remaining(), readLength); - readBuffer.get(buffer, offset, bytesRead); - - if (bytesRemaining != C.LENGTH_UNSET) { - bytesRemaining -= bytesRead; - } - bytesTransferred(bytesRead); - return bytesRead; + return readIntoByteArray(buffer, offset, readLength); } /** @@ -682,6 +655,50 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { // Internal methods. + private int readIntoByteArray(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + ByteBuffer readBuffer = this.readBuffer; + if (readBuffer == null) { + readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); + readBuffer.limit(0); + this.readBuffer = readBuffer; + } + while (!readBuffer.hasRemaining()) { + // Fill readBuffer with more data from Cronet. + operation.close(); + readBuffer.clear(); + readInternal(castNonNull(readBuffer)); + + if (finished) { + bytesRemaining = 0; + return C.RESULT_END_OF_INPUT; + } else { + // The operation didn't time out, fail or finish, and therefore data must have been read. + readBuffer.flip(); + Assertions.checkState(readBuffer.hasRemaining()); + if (bytesToSkip > 0) { + int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip); + readBuffer.position(readBuffer.position() + bytesSkipped); + bytesToSkip -= bytesSkipped; + } + } + } + + int bytesRead = Math.min(readBuffer.remaining(), readLength); + readBuffer.get(buffer, offset, bytesRead); + + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOException { UrlRequest.Builder requestBuilder = cronetEngine @@ -705,7 +722,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) { throw new IOException("HTTP request with non-empty body must set Content-Type"); } - + // Set the Range header. if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { StringBuilder rangeValue = new StringBuilder(); @@ -898,7 +915,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (responseCode == 307 || responseCode == 308) { exception = new InvalidResponseCodeException( - responseCode, info.getHttpStatusText(), info.getAllHeaders(), dataSpec); + responseCode, info.getHttpStatusText(), info.getAllHeaders(), dataSpec, null); operation.open(); return; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java index f892ca8ab1..2414ff96c1 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java @@ -315,10 +315,10 @@ public interface HttpDataSource extends DataSource { @Deprecated public InvalidResponseCodeException( int responseCode, Map> headerFields, DataSpec dataSpec) { - this(responseCode, /* responseMessage= */ null, headerFields, dataSpec); + this(responseCode, /* responseMessage= */ null, headerFields, dataSpec, null); } - /** @deprecated Use {@link #InvalidResponseCodeException(int, String, Map, DataSpec byte[])}. */ + /** @deprecated Use {@link #InvalidResponseCodeException(int, String, Map, DataSpec, byte[])}. */ @Deprecated public InvalidResponseCodeException( int responseCode, From 23e4236227bc3b85a8422ef43f2158152452097a Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 18 Feb 2020 10:29:19 +0000 Subject: [PATCH 0143/1052] Add missing IntDef to switch PiperOrigin-RevId: 295692163 --- .../main/java/com/google/android/exoplayer2/audio/WavUtil.java | 1 + 1 file changed, 1 insertion(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/WavUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/WavUtil.java index dff81021de..208989124a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/WavUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/WavUtil.java @@ -61,6 +61,7 @@ public final class WavUtil { return TYPE_PCM; case C.ENCODING_PCM_FLOAT: return TYPE_FLOAT; + case C.ENCODING_PCM_16BIT_BIG_ENDIAN: // Not TYPE_PCM, because TYPE_PCM is little endian. case C.ENCODING_INVALID: case Format.NO_VALUE: default: From ed1eade980677008eb8710b338bb1549a37df137 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 18 Feb 2020 17:00:38 +0000 Subject: [PATCH 0144/1052] Update stale comment in TrimmingAudioProcessor The part about leaving the pending trim start byte count unmodified if the processor was just configured is not correct. PiperOrigin-RevId: 295745371 --- .../exoplayer2/audio/TrimmingAudioProcessor.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java index 9437e4ac26..8d84325d93 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java @@ -155,15 +155,16 @@ import java.nio.ByteBuffer; @Override protected void onFlush() { if (reconfigurationPending) { + // This is the initial flush after reconfiguration. Prepare to trim bytes from the start/end. reconfigurationPending = false; endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame]; pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame; } else { - // Audio processors are flushed after initial configuration, so we leave the pending trim - // start byte count unmodified if the processor was just configured. Otherwise we (possibly - // incorrectly) assume that this is a seek to a non-zero position. We should instead check the - // timestamp of the first input buffer queued after flushing to decide whether to trim (see - // also [Internal: b/77292509]). + // This is a flush during playback (after the initial flush). We assume this was caused by a + // seek to a non-zero position and clear pending start bytes. This assumption may be wrong (we + // may be seeking to zero), but playing data that should have been trimmed shouldn't be + // noticeable after a seek. Ideally we would check the timestamp of the first input buffer + // queued after flushing to decide whether to trim (see also [Internal: b/77292509]). pendingTrimStartBytes = 0; } endBufferSize = 0; From 1818921a20b673f1d45f70659e8f27d4b0dc885b Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 19 Feb 2020 11:09:53 +0000 Subject: [PATCH 0145/1052] Catch-and-log all subtitle decode errors issue:#6885 PiperOrigin-RevId: 295931197 --- RELEASENOTES.md | 6 +++ .../exoplayer2/decoder/SimpleDecoder.java | 7 +++- .../android/exoplayer2/text/TextRenderer.java | 40 ++++++++++++++----- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e23bf592a5..847746d425 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,11 @@ # Release notes # +### 2.11.4 (not yet released) ### + +* Text: Catch-and-log all fatal exceptions in `TextRenderer` instead of + re-throwing, allowing playback to continue even if subtitles fail + ([#6885](https://github.com/google/ExoPlayer/issues/6885)). + ### 2.11.3 (2020-02-19) ### * SmoothStreaming: Fix regression that broke playback in 2.11.2 diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java index 03aabecb0e..4eef1ea32d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java @@ -149,6 +149,7 @@ public abstract class SimpleDecoder< while (!queuedOutputBuffers.isEmpty()) { queuedOutputBuffers.removeFirst().release(); } + exception = null; } } @@ -225,6 +226,7 @@ public abstract class SimpleDecoder< if (inputBuffer.isDecodeOnly()) { outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); } + @Nullable E exception; try { exception = decode(inputBuffer, outputBuffer, resetDecoder); } catch (RuntimeException e) { @@ -238,8 +240,9 @@ public abstract class SimpleDecoder< exception = createUnexpectedDecodeException(e); } if (exception != null) { - // Memory barrier to ensure that the decoder exception is visible from the playback thread. - synchronized (lock) {} + synchronized (lock) { + this.exception = exception; + } return false; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java index 058b1c4526..46c26db122 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java @@ -23,11 +23,11 @@ import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.BaseRenderer; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; @@ -45,6 +45,8 @@ import java.util.List; */ public final class TextRenderer extends BaseRenderer implements Callback { + private static final String TAG = "TextRenderer"; + @Documented @Retention(RetentionPolicy.SOURCE) @IntDef({ @@ -143,19 +145,13 @@ public final class TextRenderer extends BaseRenderer implements Callback { @Override protected void onPositionReset(long positionUs, boolean joining) { - clearOutput(); inputStreamEnded = false; outputStreamEnded = false; - if (decoderReplacementState != REPLACEMENT_STATE_NONE) { - replaceDecoder(); - } else { - releaseBuffers(); - decoder.flush(); - } + resetOutputAndDecoder(); } @Override - public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + public void render(long positionUs, long elapsedRealtimeUs) { if (outputStreamEnded) { return; } @@ -165,7 +161,8 @@ public final class TextRenderer extends BaseRenderer implements Callback { try { nextSubtitle = decoder.dequeueOutputBuffer(); } catch (SubtitleDecoderException e) { - throw createRendererException(e, streamFormat); + handleDecoderError(e); + return; } } @@ -247,7 +244,8 @@ public final class TextRenderer extends BaseRenderer implements Callback { } } } catch (SubtitleDecoderException e) { - throw createRendererException(e, streamFormat); + handleDecoderError(e); + return; } } @@ -329,4 +327,24 @@ public final class TextRenderer extends BaseRenderer implements Callback { output.onCues(cues); } + /** + * Called when {@link #decoder} throws an exception, so it can be logged and playback can + * continue. + * + *

    Logs {@code e} and resets state to allow decoding the next sample. + */ + private void handleDecoderError(SubtitleDecoderException e) { + Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e); + resetOutputAndDecoder(); + } + + private void resetOutputAndDecoder() { + clearOutput(); + if (decoderReplacementState != REPLACEMENT_STATE_NONE) { + replaceDecoder(); + } else { + releaseBuffers(); + decoder.flush(); + } + } } From 11635191a63e5ba1e1c0bf5b046faeb1b6cfb9a6 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 7 Feb 2020 00:57:15 +0000 Subject: [PATCH 0146/1052] Switch to new libgav1 frame buffer callback API. The new code in Libgav1GetFrameBuffer is copied from libgav1/src/frame_buffer_callback_adaptor.cc. It may become libgav1 utility functions available to libgav1 clients in the future. The Libgav1FrameBuffer struct in the old frame buffer callback API is defined as follows: typedef struct Libgav1FrameBuffer { uint8_t* data[3]; size_t size[3]; void* private_data; } Libgav1FrameBuffer; Copy these three fields to the JniFrameBuffer class as private data members and add the RawBuffer() and Id() getter methods. The existing AlignTo16 function is replaced by the copied Align template function. PiperOrigin-RevId: 293709205 --- extensions/av1/src/main/jni/gav1_jni.cc | 182 +++++++++++++++++++----- 1 file changed, 146 insertions(+), 36 deletions(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 9ac3ea5cd2..29ef3f0ec4 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -27,6 +27,7 @@ #endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON #include +#include #include #include // NOLINT #include @@ -121,15 +122,13 @@ const char* GetJniErrorMessage(JniStatusCode error_code) { } } -// Manages Libgav1FrameBuffer and reference information. +// Manages frame buffer and reference information. class JniFrameBuffer { public: - explicit JniFrameBuffer(int id) : id_(id), reference_count_(0) { - gav1_frame_buffer_.private_data = &id_; - } + explicit JniFrameBuffer(int id) : id_(id), reference_count_(0) {} ~JniFrameBuffer() { for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) { - delete[] gav1_frame_buffer_.data[plane_index]; + delete[] raw_buffer_[plane_index]; } } @@ -160,9 +159,8 @@ class JniFrameBuffer { void RemoveReference() { reference_count_--; } bool InUse() const { return reference_count_ != 0; } - const Libgav1FrameBuffer& GetGav1FrameBuffer() const { - return gav1_frame_buffer_; - } + uint8_t* RawBuffer(int plane_index) const { return raw_buffer_[plane_index]; } + int Id() const { return id_; } // Attempts to reallocate data planes if the existing ones don't have enough // capacity. Returns true if the allocation was successful or wasn't needed, @@ -172,15 +170,14 @@ class JniFrameBuffer { for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) { const int min_size = (plane_index == kPlaneY) ? y_plane_min_size : uv_plane_min_size; - if (gav1_frame_buffer_.size[plane_index] >= min_size) continue; - delete[] gav1_frame_buffer_.data[plane_index]; - gav1_frame_buffer_.data[plane_index] = - new (std::nothrow) uint8_t[min_size]; - if (!gav1_frame_buffer_.data[plane_index]) { - gav1_frame_buffer_.size[plane_index] = 0; + if (raw_buffer_size_[plane_index] >= min_size) continue; + delete[] raw_buffer_[plane_index]; + raw_buffer_[plane_index] = new (std::nothrow) uint8_t[min_size]; + if (!raw_buffer_[plane_index]) { + raw_buffer_size_[plane_index] = 0; return false; } - gav1_frame_buffer_.size[plane_index] = min_size; + raw_buffer_size_[plane_index] = min_size; } return true; } @@ -190,9 +187,12 @@ class JniFrameBuffer { uint8_t* plane_[kMaxPlanes]; int displayed_width_[kMaxPlanes]; int displayed_height_[kMaxPlanes]; - int id_; + const int id_; int reference_count_; - Libgav1FrameBuffer gav1_frame_buffer_ = {}; + // Pointers to the raw buffers allocated for the data planes. + uint8_t* raw_buffer_[kMaxPlanes] = {}; + // Sizes of the raw buffers in bytes. + size_t raw_buffer_size_[kMaxPlanes] = {}; }; // Manages frame buffers used by libgav1 decoder and ExoPlayer. @@ -210,7 +210,7 @@ class JniBufferManager { } JniStatusCode GetBuffer(size_t y_plane_min_size, size_t uv_plane_min_size, - Libgav1FrameBuffer* frame_buffer) { + JniFrameBuffer** jni_buffer) { std::lock_guard lock(mutex_); JniFrameBuffer* output_buffer; @@ -230,7 +230,7 @@ class JniBufferManager { } output_buffer->AddReference(); - *frame_buffer = output_buffer->GetGav1FrameBuffer(); + *jni_buffer = output_buffer; return kJniStatusOk; } @@ -316,33 +316,142 @@ struct JniContext { JniStatusCode jni_status_code = kJniStatusOk; }; -int Libgav1GetFrameBuffer(void* private_data, size_t y_plane_min_size, - size_t uv_plane_min_size, - Libgav1FrameBuffer* frame_buffer) { - JniContext* const context = reinterpret_cast(private_data); +// Aligns |value| to the desired |alignment|. |alignment| must be a power of 2. +template +constexpr T Align(T value, T alignment) { + const T alignment_mask = alignment - 1; + return (value + alignment_mask) & ~alignment_mask; +} + +// Aligns |addr| to the desired |alignment|. |alignment| must be a power of 2. +uint8_t* AlignAddr(uint8_t* const addr, const size_t alignment) { + const auto value = reinterpret_cast(addr); + return reinterpret_cast(Align(value, alignment)); +} + +// Libgav1 frame buffer callbacks return 0 on success, -1 on failure. + +int Libgav1OnFrameBufferSizeChanged(void* /*callback_private_data*/, + int /*bitdepth*/, + libgav1::ImageFormat /*image_format*/, + int /*width*/, int /*height*/, + int /*left_border*/, int /*right_border*/, + int /*top_border*/, int /*bottom_border*/, + int /*stride_alignment*/) { + // The libgav1 decoder calls this callback to provide information on the + // subsequent frames in the video. JniBufferManager ignores this information. + return 0; +} + +int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, + libgav1::ImageFormat image_format, int width, + int height, int left_border, int right_border, + int top_border, int bottom_border, + int stride_alignment, + libgav1::FrameBuffer2* frame_buffer) { + bool is_monochrome = false; + int8_t subsampling_x = 1; + int8_t subsampling_y = 1; + switch (image_format) { + case libgav1::kImageFormatYuv420: + break; + case libgav1::kImageFormatYuv422: + subsampling_y = 0; + break; + case libgav1::kImageFormatYuv444: + subsampling_x = subsampling_y = 0; + break; + default: + // image_format is libgav1::kImageFormatMonochrome400. (AV1 has only four + // image formats, hardcoded in the spec). + is_monochrome = true; + break; + } + + // Calculate y_stride (in bytes). It is padded to a multiple of + // |stride_alignment| bytes. + int y_stride = width + left_border + right_border; + if (bitdepth > 8) y_stride *= sizeof(uint16_t); + y_stride = Align(y_stride, stride_alignment); + // Size of the Y plane in bytes. + const uint64_t y_plane_size = + (height + top_border + bottom_border) * static_cast(y_stride) + + (stride_alignment - 1); + + const int uv_width = is_monochrome ? 0 : width >> subsampling_x; + const int uv_height = is_monochrome ? 0 : height >> subsampling_y; + const int uv_left_border = is_monochrome ? 0 : left_border >> subsampling_x; + const int uv_right_border = is_monochrome ? 0 : right_border >> subsampling_x; + const int uv_top_border = is_monochrome ? 0 : top_border >> subsampling_y; + const int uv_bottom_border = + is_monochrome ? 0 : bottom_border >> subsampling_y; + + // Calculate uv_stride (in bytes). It is padded to a multiple of + // |stride_alignment| bytes. + int uv_stride = uv_width + uv_left_border + uv_right_border; + if (bitdepth > 8) uv_stride *= sizeof(uint16_t); + uv_stride = Align(uv_stride, stride_alignment); + // Size of the U or V plane in bytes. + const uint64_t uv_plane_size = + is_monochrome ? 0 + : (uv_height + uv_top_border + uv_bottom_border) * + static_cast(uv_stride) + + (stride_alignment - 1); + + // Check if it is safe to cast y_plane_size and uv_plane_size to size_t. + if (y_plane_size > SIZE_MAX || uv_plane_size > SIZE_MAX) { + return -1; + } + + JniContext* const context = + reinterpret_cast(callback_private_data); + JniFrameBuffer* jni_buffer; context->jni_status_code = context->buffer_manager.GetBuffer( - y_plane_min_size, uv_plane_min_size, frame_buffer); + static_cast(y_plane_size), static_cast(uv_plane_size), + &jni_buffer); if (context->jni_status_code != kJniStatusOk) { LOGE("%s", GetJniErrorMessage(context->jni_status_code)); return -1; } + + uint8_t* const y_buffer = jni_buffer->RawBuffer(0); + uint8_t* const u_buffer = !is_monochrome ? jni_buffer->RawBuffer(1) : nullptr; + uint8_t* const v_buffer = !is_monochrome ? jni_buffer->RawBuffer(2) : nullptr; + + int left_border_bytes = left_border; + int uv_left_border_bytes = uv_left_border; + if (bitdepth > 8) { + left_border_bytes *= sizeof(uint16_t); + uv_left_border_bytes *= sizeof(uint16_t); + } + frame_buffer->plane[0] = AlignAddr( + y_buffer + (top_border * y_stride) + left_border_bytes, stride_alignment); + frame_buffer->plane[1] = + AlignAddr(u_buffer + (uv_top_border * uv_stride) + uv_left_border_bytes, + stride_alignment); + frame_buffer->plane[2] = + AlignAddr(v_buffer + (uv_top_border * uv_stride) + uv_left_border_bytes, + stride_alignment); + + frame_buffer->stride[0] = y_stride; + frame_buffer->stride[1] = frame_buffer->stride[2] = uv_stride; + + frame_buffer->private_data = reinterpret_cast(jni_buffer->Id()); + return 0; } -int Libgav1ReleaseFrameBuffer(void* private_data, - Libgav1FrameBuffer* frame_buffer) { - JniContext* const context = reinterpret_cast(private_data); - const int buffer_id = *reinterpret_cast(frame_buffer->private_data); +void Libgav1ReleaseFrameBuffer(void* callback_private_data, + void* buffer_private_data) { + JniContext* const context = + reinterpret_cast(callback_private_data); + const int buffer_id = reinterpret_cast(buffer_private_data); context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id); if (context->jni_status_code != kJniStatusOk) { LOGE("%s", GetJniErrorMessage(context->jni_status_code)); - return -1; } - return 0; } -constexpr int AlignTo16(int value) { return (value + 15) & (~15); } - void CopyPlane(const uint8_t* source, int source_stride, uint8_t* destination, int destination_stride, int width, int height) { while (height--) { @@ -508,8 +617,9 @@ DECODER_FUNC(jlong, gav1Init, jint threads) { libgav1::DecoderSettings settings; settings.threads = threads; - settings.get = Libgav1GetFrameBuffer; - settings.release = Libgav1ReleaseFrameBuffer; + settings.on_frame_buffer_size_changed = Libgav1OnFrameBufferSizeChanged; + settings.get_frame_buffer = Libgav1GetFrameBuffer; + settings.release_frame_buffer = Libgav1ReleaseFrameBuffer; settings.callback_private_data = context; context->libgav1_status_code = context->decoder.Init(&settings); @@ -619,7 +729,7 @@ DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer, } const int buffer_id = - *reinterpret_cast(decoder_buffer->buffer_private_data); + reinterpret_cast(decoder_buffer->buffer_private_data); context->buffer_manager.AddBufferReference(buffer_id); JniFrameBuffer* const jni_buffer = context->buffer_manager.GetBuffer(buffer_id); @@ -680,7 +790,7 @@ DECODER_FUNC(jint, gav1RenderFrame, jlong jContext, jobject jSurface, const int32_t native_window_buffer_uv_height = (native_window_buffer.height + 1) / 2; const int native_window_buffer_uv_stride = - AlignTo16(native_window_buffer.stride / 2); + Align(native_window_buffer.stride / 2, 16); // TODO(b/140606738): Handle monochrome videos. From 0acc95cfbbb9c164c7321d7be98d425430245907 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 7 Feb 2020 15:51:27 +0000 Subject: [PATCH 0147/1052] Cast void* to JniContext* using static_cast. static_cast is more appropriate than reinrerpret_cast for casting from void* to JniContext*. See Section 7.2.1 (page 173) in The C++ Programming Language, 4th Edition and https://stackoverflow.com/questions/310451/should-i-use-static-cast-or-reinterpret-cast-when-casting-a-void-to-whatever PiperOrigin-RevId: 293812940 --- extensions/av1/src/main/jni/gav1_jni.cc | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 29ef3f0ec4..8ba4413fea 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -403,8 +403,7 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, return -1; } - JniContext* const context = - reinterpret_cast(callback_private_data); + JniContext* const context = static_cast(callback_private_data); JniFrameBuffer* jni_buffer; context->jni_status_code = context->buffer_manager.GetBuffer( static_cast(y_plane_size), static_cast(uv_plane_size), @@ -443,8 +442,7 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, void Libgav1ReleaseFrameBuffer(void* callback_private_data, void* buffer_private_data) { - JniContext* const context = - reinterpret_cast(callback_private_data); + JniContext* const context = static_cast(callback_private_data); const int buffer_id = reinterpret_cast(buffer_private_data); context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id); if (context->jni_status_code != kJniStatusOk) { From b73f6b6ef06bd2c2af6e490bf3a47cf2cebab864 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 14 Feb 2020 21:16:57 +0000 Subject: [PATCH 0148/1052] gav1_jni: fix pointer->int conversion warnings fixes: gav1_jni.cc:446:25: error: cast from pointer to smaller type 'int' loses information const int buffer_id = reinterpret_cast(buffer_private_data); ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ gav1_jni.cc:730:9: error: cast from pointer to smaller type 'int' loses information reinterpret_cast(decoder_buffer->buffer_private_data); ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ after https://github.com/google/ExoPlayer/commit/0915998add5918214fa0282a69b50a159168a6d5 PiperOrigin-RevId: 295211245 --- extensions/av1/src/main/jni/gav1_jni.cc | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 8ba4413fea..30c88693df 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -27,6 +27,8 @@ #endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON #include +#include +#include #include #include #include // NOLINT @@ -443,8 +445,10 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, void Libgav1ReleaseFrameBuffer(void* callback_private_data, void* buffer_private_data) { JniContext* const context = static_cast(callback_private_data); - const int buffer_id = reinterpret_cast(buffer_private_data); - context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id); + const intptr_t buffer_id = reinterpret_cast(buffer_private_data); + assert(buffer_id <= INT_MAX); + context->jni_status_code = + context->buffer_manager.ReleaseBuffer(static_cast(buffer_id)); if (context->jni_status_code != kJniStatusOk) { LOGE("%s", GetJniErrorMessage(context->jni_status_code)); } @@ -726,11 +730,12 @@ DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer, return kStatusError; } - const int buffer_id = - reinterpret_cast(decoder_buffer->buffer_private_data); - context->buffer_manager.AddBufferReference(buffer_id); + const intptr_t buffer_id = + reinterpret_cast(decoder_buffer->buffer_private_data); + assert(buffer_id <= INT_MAX); + context->buffer_manager.AddBufferReference(static_cast(buffer_id)); JniFrameBuffer* const jni_buffer = - context->buffer_manager.GetBuffer(buffer_id); + context->buffer_manager.GetBuffer(static_cast(buffer_id)); jni_buffer->SetFrameData(*decoder_buffer); env->CallVoidMethod(jOutputBuffer, context->init_for_private_frame_method, decoder_buffer->displayed_width[kPlaneY], @@ -739,7 +744,8 @@ DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer, // Exception is thrown in Java when returning from the native call. return kStatusError; } - env->SetIntField(jOutputBuffer, context->decoder_private_field, buffer_id); + env->SetIntField(jOutputBuffer, context->decoder_private_field, + static_cast(buffer_id)); } return kStatusOk; From 766b383d274ebbf9ebe387b0abe8df9dc323f8eb Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 17 Feb 2020 11:00:56 +0000 Subject: [PATCH 0149/1052] Use libgav1 frame buffer callback helper functions Libgav1 recently added the ComputeFrameBufferInfo() and SetFrameBuffer() helper functions for writing frame buffer callbacks. Using them simplifies the Libgav1GetFrameBuffer() function. Also resurrect the AlignTo16() function. PiperOrigin-RevId: 295548330 --- extensions/av1/src/main/jni/gav1_jni.cc | 109 ++++-------------------- 1 file changed, 17 insertions(+), 92 deletions(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 30c88693df..4ce614a3d9 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -318,19 +318,6 @@ struct JniContext { JniStatusCode jni_status_code = kJniStatusOk; }; -// Aligns |value| to the desired |alignment|. |alignment| must be a power of 2. -template -constexpr T Align(T value, T alignment) { - const T alignment_mask = alignment - 1; - return (value + alignment_mask) & ~alignment_mask; -} - -// Aligns |addr| to the desired |alignment|. |alignment| must be a power of 2. -uint8_t* AlignAddr(uint8_t* const addr, const size_t alignment) { - const auto value = reinterpret_cast(addr); - return reinterpret_cast(Align(value, alignment)); -} - // Libgav1 frame buffer callbacks return 0 on success, -1 on failure. int Libgav1OnFrameBufferSizeChanged(void* /*callback_private_data*/, @@ -351,95 +338,31 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, int top_border, int bottom_border, int stride_alignment, libgav1::FrameBuffer2* frame_buffer) { - bool is_monochrome = false; - int8_t subsampling_x = 1; - int8_t subsampling_y = 1; - switch (image_format) { - case libgav1::kImageFormatYuv420: - break; - case libgav1::kImageFormatYuv422: - subsampling_y = 0; - break; - case libgav1::kImageFormatYuv444: - subsampling_x = subsampling_y = 0; - break; - default: - // image_format is libgav1::kImageFormatMonochrome400. (AV1 has only four - // image formats, hardcoded in the spec). - is_monochrome = true; - break; - } - - // Calculate y_stride (in bytes). It is padded to a multiple of - // |stride_alignment| bytes. - int y_stride = width + left_border + right_border; - if (bitdepth > 8) y_stride *= sizeof(uint16_t); - y_stride = Align(y_stride, stride_alignment); - // Size of the Y plane in bytes. - const uint64_t y_plane_size = - (height + top_border + bottom_border) * static_cast(y_stride) + - (stride_alignment - 1); - - const int uv_width = is_monochrome ? 0 : width >> subsampling_x; - const int uv_height = is_monochrome ? 0 : height >> subsampling_y; - const int uv_left_border = is_monochrome ? 0 : left_border >> subsampling_x; - const int uv_right_border = is_monochrome ? 0 : right_border >> subsampling_x; - const int uv_top_border = is_monochrome ? 0 : top_border >> subsampling_y; - const int uv_bottom_border = - is_monochrome ? 0 : bottom_border >> subsampling_y; - - // Calculate uv_stride (in bytes). It is padded to a multiple of - // |stride_alignment| bytes. - int uv_stride = uv_width + uv_left_border + uv_right_border; - if (bitdepth > 8) uv_stride *= sizeof(uint16_t); - uv_stride = Align(uv_stride, stride_alignment); - // Size of the U or V plane in bytes. - const uint64_t uv_plane_size = - is_monochrome ? 0 - : (uv_height + uv_top_border + uv_bottom_border) * - static_cast(uv_stride) + - (stride_alignment - 1); - - // Check if it is safe to cast y_plane_size and uv_plane_size to size_t. - if (y_plane_size > SIZE_MAX || uv_plane_size > SIZE_MAX) { - return -1; - } + libgav1::FrameBufferInfo info; + Libgav1StatusCode status = libgav1::ComputeFrameBufferInfo( + bitdepth, image_format, width, height, left_border, right_border, + top_border, bottom_border, stride_alignment, &info); + if (status != kLibgav1StatusOk) return -1; JniContext* const context = static_cast(callback_private_data); JniFrameBuffer* jni_buffer; context->jni_status_code = context->buffer_manager.GetBuffer( - static_cast(y_plane_size), static_cast(uv_plane_size), - &jni_buffer); + info.y_buffer_size, info.uv_buffer_size, &jni_buffer); if (context->jni_status_code != kJniStatusOk) { LOGE("%s", GetJniErrorMessage(context->jni_status_code)); return -1; } uint8_t* const y_buffer = jni_buffer->RawBuffer(0); - uint8_t* const u_buffer = !is_monochrome ? jni_buffer->RawBuffer(1) : nullptr; - uint8_t* const v_buffer = !is_monochrome ? jni_buffer->RawBuffer(2) : nullptr; + uint8_t* const u_buffer = + (info.uv_buffer_size != 0) ? jni_buffer->RawBuffer(1) : nullptr; + uint8_t* const v_buffer = + (info.uv_buffer_size != 0) ? jni_buffer->RawBuffer(2) : nullptr; - int left_border_bytes = left_border; - int uv_left_border_bytes = uv_left_border; - if (bitdepth > 8) { - left_border_bytes *= sizeof(uint16_t); - uv_left_border_bytes *= sizeof(uint16_t); - } - frame_buffer->plane[0] = AlignAddr( - y_buffer + (top_border * y_stride) + left_border_bytes, stride_alignment); - frame_buffer->plane[1] = - AlignAddr(u_buffer + (uv_top_border * uv_stride) + uv_left_border_bytes, - stride_alignment); - frame_buffer->plane[2] = - AlignAddr(v_buffer + (uv_top_border * uv_stride) + uv_left_border_bytes, - stride_alignment); - - frame_buffer->stride[0] = y_stride; - frame_buffer->stride[1] = frame_buffer->stride[2] = uv_stride; - - frame_buffer->private_data = reinterpret_cast(jni_buffer->Id()); - - return 0; + status = libgav1::SetFrameBuffer(&info, y_buffer, u_buffer, v_buffer, + reinterpret_cast(jni_buffer->Id()), + frame_buffer); + return (status == kLibgav1StatusOk) ? 0 : -1; } void Libgav1ReleaseFrameBuffer(void* callback_private_data, @@ -454,6 +377,8 @@ void Libgav1ReleaseFrameBuffer(void* callback_private_data, } } +constexpr int AlignTo16(int value) { return (value + 15) & (~15); } + void CopyPlane(const uint8_t* source, int source_stride, uint8_t* destination, int destination_stride, int width, int height) { while (height--) { @@ -794,7 +719,7 @@ DECODER_FUNC(jint, gav1RenderFrame, jlong jContext, jobject jSurface, const int32_t native_window_buffer_uv_height = (native_window_buffer.height + 1) / 2; const int native_window_buffer_uv_stride = - Align(native_window_buffer.stride / 2, 16); + AlignTo16(native_window_buffer.stride / 2); // TODO(b/140606738): Handle monochrome videos. From 75bd4ebc1800e5960e8badfc0e7e18d1e97821a3 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 17 Feb 2020 11:22:38 +0000 Subject: [PATCH 0150/1052] Use &id_ as buffer_private_data for libgav1. This avoids the issue of whether it is defined behaviour to cast an arbitrary int (or even intptr_t) value to a void* pointer. This is the original approach used before commit 0915998add5918214fa0282a69b50a159168a6d5. PiperOrigin-RevId: 295552115 --- extensions/av1/src/main/jni/gav1_jni.cc | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 4ce614a3d9..5be4f5b923 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -27,8 +27,6 @@ #endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON #include -#include -#include #include #include #include // NOLINT @@ -134,6 +132,12 @@ class JniFrameBuffer { } } + // Not copyable or movable. + JniFrameBuffer(const JniFrameBuffer&) = delete; + JniFrameBuffer(JniFrameBuffer&&) = delete; + JniFrameBuffer& operator=(const JniFrameBuffer&) = delete; + JniFrameBuffer& operator=(JniFrameBuffer&&) = delete; + void SetFrameData(const libgav1::DecoderBuffer& decoder_buffer) { for (int plane_index = kPlaneY; plane_index < decoder_buffer.NumPlanes(); plane_index++) { @@ -162,7 +166,7 @@ class JniFrameBuffer { bool InUse() const { return reference_count_ != 0; } uint8_t* RawBuffer(int plane_index) const { return raw_buffer_[plane_index]; } - int Id() const { return id_; } + void* BufferPrivateData() const { return const_cast(&id_); } // Attempts to reallocate data planes if the existing ones don't have enough // capacity. Returns true if the allocation was successful or wasn't needed, @@ -359,19 +363,17 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, uint8_t* const v_buffer = (info.uv_buffer_size != 0) ? jni_buffer->RawBuffer(2) : nullptr; - status = libgav1::SetFrameBuffer(&info, y_buffer, u_buffer, v_buffer, - reinterpret_cast(jni_buffer->Id()), - frame_buffer); + status = + libgav1::SetFrameBuffer(&info, y_buffer, u_buffer, v_buffer, + jni_buffer->BufferPrivateData(), frame_buffer); return (status == kLibgav1StatusOk) ? 0 : -1; } void Libgav1ReleaseFrameBuffer(void* callback_private_data, void* buffer_private_data) { JniContext* const context = static_cast(callback_private_data); - const intptr_t buffer_id = reinterpret_cast(buffer_private_data); - assert(buffer_id <= INT_MAX); - context->jni_status_code = - context->buffer_manager.ReleaseBuffer(static_cast(buffer_id)); + const int buffer_id = *static_cast(buffer_private_data); + context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id); if (context->jni_status_code != kJniStatusOk) { LOGE("%s", GetJniErrorMessage(context->jni_status_code)); } @@ -655,12 +657,11 @@ DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer, return kStatusError; } - const intptr_t buffer_id = - reinterpret_cast(decoder_buffer->buffer_private_data); - assert(buffer_id <= INT_MAX); - context->buffer_manager.AddBufferReference(static_cast(buffer_id)); + const int buffer_id = + *static_cast(decoder_buffer->buffer_private_data); + context->buffer_manager.AddBufferReference(buffer_id); JniFrameBuffer* const jni_buffer = - context->buffer_manager.GetBuffer(static_cast(buffer_id)); + context->buffer_manager.GetBuffer(buffer_id); jni_buffer->SetFrameData(*decoder_buffer); env->CallVoidMethod(jOutputBuffer, context->init_for_private_frame_method, decoder_buffer->displayed_width[kPlaneY], @@ -669,8 +670,7 @@ DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer, // Exception is thrown in Java when returning from the native call. return kStatusError; } - env->SetIntField(jOutputBuffer, context->decoder_private_field, - static_cast(buffer_id)); + env->SetIntField(jOutputBuffer, context->decoder_private_field, buffer_id); } return kStatusOk; From 908a76e8515073c721eeffb1205b5799af9f2efa Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 19 Feb 2020 16:09:10 +0000 Subject: [PATCH 0151/1052] Change libgav1's frame buffer callback API. 1. Have frame buffer callbacks return Libgav1StatusCode instead of int. The 0 (success), -1 (failure) return value convention is less obvious. Note: The callers of frame buffer callbacks, BufferPool::OnFrameBufferSizeChanged() and YuvBuffer::Realloc(), currently return bool, so more work is needed to propagate the frame buffer callbacks' Libgav1StatusCode return value to the Decoder API. 2. Allow the FrameBufferSizeChangedCallback to be omitted if the frame buffer size information is not useful to the application. 3. Remove the old (version 1) frame buffer callback API. Remove the frame buffer callback adaptor. frame_buffer2.h is renamed frame_buffer.h. Libgav1FrameBuffer2 is renamed Libgav1FrameBuffer. GetFrameBufferCallback2 and ReleaseFrameBufferCallback2 are renamed GetFrameBufferCallback and ReleaseFrameBufferCallback. PiperOrigin-RevId: 295971183 --- extensions/av1/src/main/jni/gav1_jni.cc | 38 +++++++------------------ 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 5be4f5b923..e3fb890c32 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -322,31 +322,18 @@ struct JniContext { JniStatusCode jni_status_code = kJniStatusOk; }; -// Libgav1 frame buffer callbacks return 0 on success, -1 on failure. - -int Libgav1OnFrameBufferSizeChanged(void* /*callback_private_data*/, - int /*bitdepth*/, - libgav1::ImageFormat /*image_format*/, - int /*width*/, int /*height*/, - int /*left_border*/, int /*right_border*/, - int /*top_border*/, int /*bottom_border*/, - int /*stride_alignment*/) { - // The libgav1 decoder calls this callback to provide information on the - // subsequent frames in the video. JniBufferManager ignores this information. - return 0; -} - -int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, - libgav1::ImageFormat image_format, int width, - int height, int left_border, int right_border, - int top_border, int bottom_border, - int stride_alignment, - libgav1::FrameBuffer2* frame_buffer) { +Libgav1StatusCode Libgav1GetFrameBuffer(void* callback_private_data, + int bitdepth, + libgav1::ImageFormat image_format, + int width, int height, int left_border, + int right_border, int top_border, + int bottom_border, int stride_alignment, + libgav1::FrameBuffer* frame_buffer) { libgav1::FrameBufferInfo info; Libgav1StatusCode status = libgav1::ComputeFrameBufferInfo( bitdepth, image_format, width, height, left_border, right_border, top_border, bottom_border, stride_alignment, &info); - if (status != kLibgav1StatusOk) return -1; + if (status != kLibgav1StatusOk) return status; JniContext* const context = static_cast(callback_private_data); JniFrameBuffer* jni_buffer; @@ -354,7 +341,7 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, info.y_buffer_size, info.uv_buffer_size, &jni_buffer); if (context->jni_status_code != kJniStatusOk) { LOGE("%s", GetJniErrorMessage(context->jni_status_code)); - return -1; + return kLibgav1StatusOutOfMemory; } uint8_t* const y_buffer = jni_buffer->RawBuffer(0); @@ -363,10 +350,8 @@ int Libgav1GetFrameBuffer(void* callback_private_data, int bitdepth, uint8_t* const v_buffer = (info.uv_buffer_size != 0) ? jni_buffer->RawBuffer(2) : nullptr; - status = - libgav1::SetFrameBuffer(&info, y_buffer, u_buffer, v_buffer, - jni_buffer->BufferPrivateData(), frame_buffer); - return (status == kLibgav1StatusOk) ? 0 : -1; + return libgav1::SetFrameBuffer(&info, y_buffer, u_buffer, v_buffer, + jni_buffer->BufferPrivateData(), frame_buffer); } void Libgav1ReleaseFrameBuffer(void* callback_private_data, @@ -546,7 +531,6 @@ DECODER_FUNC(jlong, gav1Init, jint threads) { libgav1::DecoderSettings settings; settings.threads = threads; - settings.on_frame_buffer_size_changed = Libgav1OnFrameBufferSizeChanged; settings.get_frame_buffer = Libgav1GetFrameBuffer; settings.release_frame_buffer = Libgav1ReleaseFrameBuffer; settings.callback_private_data = context; From 8591e69b6a40162ffc801a1569549ca826606cc5 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Mon, 30 Mar 2020 13:46:24 +0100 Subject: [PATCH 0152/1052] Remove duplicate SCTE-35 format and add sample to TsExtractorTest It's not clear why we're currently outputting the format in both init() and consume() - it seems likely that this was accidentally introduced in when we started outputting the format in consume() but didn't remove it from init(). --- .../exoplayer2/extractor/ts/SpliceInfoSectionReader.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java index 27838d4c25..ab67439607 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java @@ -38,8 +38,6 @@ public final class SpliceInfoSectionReader implements SectionPayloadReader { this.timestampAdjuster = timestampAdjuster; idGenerator.generateNewId(); output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_METADATA); - output.format(Format.createSampleFormat(idGenerator.getFormatId(), MimeTypes.APPLICATION_SCTE35, - null, Format.NO_VALUE, null)); } @Override From 265670cf93fa9d688df4db55a296214ad9630501 Mon Sep 17 00:00:00 2001 From: vigneshv Date: Thu, 20 Feb 2020 20:52:37 +0000 Subject: [PATCH 0153/1052] Add ReleaseInputBuffer callback The release_input_buffer callback will be called when the library is done consuming an "input buffer". The buffer passed into EnqueueFrame must be kept valid until this callback is called. If frame parallel is false, then this callback can be nullptr (in this case the buffer has to be kept valid until the next call to DequeueFrame). If frame parallel is true, this callback cannot be nullptr. PiperOrigin-RevId: 296276083 --- extensions/av1/src/main/jni/gav1_jni.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index e3fb890c32..e0cef86d22 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -567,7 +567,8 @@ DECODER_FUNC(jint, gav1Decode, jlong jContext, jobject encodedData, const uint8_t* const buffer = reinterpret_cast( env->GetDirectBufferAddress(encodedData)); context->libgav1_status_code = - context->decoder.EnqueueFrame(buffer, length, /*user_private_data=*/0); + context->decoder.EnqueueFrame(buffer, length, /*user_private_data=*/0, + /*buffer_private_data=*/nullptr); if (context->libgav1_status_code != kLibgav1StatusOk) { return kStatusError; } From c3f9f0e5fec9735d36dcc83afc8faf03325121d7 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Tue, 25 Feb 2020 21:24:27 +0000 Subject: [PATCH 0154/1052] Merge pull request #7010 from dbrain:fix_nullability PiperOrigin-RevId: 297187116 --- .../exoplayer2/offline/DownloadHelper.java | 26 +++++++++---------- .../exoplayer2/offline/DownloadService.java | 12 ++++++--- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index d176b1905c..6707c1e496 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -245,8 +245,8 @@ public final class DownloadHelper { * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. + * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which + * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. * @return A {@link DownloadHelper} for DASH streams. @@ -315,8 +315,8 @@ public final class DownloadHelper { * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. + * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which + * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. * @return A {@link DownloadHelper} for HLS streams. @@ -385,8 +385,8 @@ public final class DownloadHelper { * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. + * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which + * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. * @return A {@link DownloadHelper} for SmoothStreaming streams. @@ -414,27 +414,27 @@ public final class DownloadHelper { /** * Equivalent to {@link #createMediaSource(DownloadRequest, Factory, DrmSessionManager) - * createMediaSource(downloadRequest, dataSourceFactory, - * DrmSessionManager.getDummyDrmSessionManager())}. + * createMediaSource(downloadRequest, dataSourceFactory, null)}. */ public static MediaSource createMediaSource( DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory) { - return createMediaSource( - downloadRequest, dataSourceFactory, DrmSessionManager.getDummyDrmSessionManager()); + return createMediaSource(downloadRequest, dataSourceFactory, /* drmSessionManager= */ null); } /** - * Utility method to create a MediaSource which only contains the tracks defined in {@code + * Utility method to create a {@link MediaSource} that only exposes the tracks defined in {@code * downloadRequest}. * * @param downloadRequest A {@link DownloadRequest}. * @param dataSourceFactory A factory for {@link DataSource}s to read the media. - * @return A MediaSource which only contains the tracks defined in {@code downloadRequest}. + * @param drmSessionManager An optional {@link DrmSessionManager} to be passed to the {@link + * MediaSource}. + * @return A {@link MediaSource} that only exposes the tracks defined in {@code downloadRequest}. */ public static MediaSource createMediaSource( DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory, - DrmSessionManager drmSessionManager) { + @Nullable DrmSessionManager drmSessionManager) { @Nullable Constructor constructor; switch (downloadRequest.type) { case DownloadRequest.TYPE_DASH: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index b1ab5ac7c6..819478b80e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -595,7 +595,7 @@ public abstract class DownloadService extends Service { } @Override - public int onStartCommand(Intent intent, int flags, int startId) { + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { lastStartId = startId; taskRemoved = false; @Nullable String intentAction = null; @@ -617,7 +617,9 @@ public abstract class DownloadService extends Service { // Do nothing. break; case ACTION_ADD_DOWNLOAD: - @Nullable DownloadRequest downloadRequest = intent.getParcelableExtra(KEY_DOWNLOAD_REQUEST); + @Nullable + DownloadRequest downloadRequest = + Assertions.checkNotNull(intent).getParcelableExtra(KEY_DOWNLOAD_REQUEST); if (downloadRequest == null) { Log.e(TAG, "Ignored ADD_DOWNLOAD: Missing " + KEY_DOWNLOAD_REQUEST + " extra"); } else { @@ -642,7 +644,7 @@ public abstract class DownloadService extends Service { downloadManager.pauseDownloads(); break; case ACTION_SET_STOP_REASON: - if (!intent.hasExtra(KEY_STOP_REASON)) { + if (!Assertions.checkNotNull(intent).hasExtra(KEY_STOP_REASON)) { Log.e(TAG, "Ignored SET_STOP_REASON: Missing " + KEY_STOP_REASON + " extra"); } else { int stopReason = intent.getIntExtra(KEY_STOP_REASON, /* defaultValue= */ 0); @@ -650,7 +652,9 @@ public abstract class DownloadService extends Service { } break; case ACTION_SET_REQUIREMENTS: - @Nullable Requirements requirements = intent.getParcelableExtra(KEY_REQUIREMENTS); + @Nullable + Requirements requirements = + Assertions.checkNotNull(intent).getParcelableExtra(KEY_REQUIREMENTS); if (requirements == null) { Log.e(TAG, "Ignored SET_REQUIREMENTS: Missing " + KEY_REQUIREMENTS + " extra"); } else { From e2a6775ea44935c8fe78fc49f6698989e29c5454 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Tue, 10 Mar 2020 10:17:13 +0000 Subject: [PATCH 0155/1052] Merge pull request #6999 from xufuji456:dev-v2 PiperOrigin-RevId: 298544278 --- .../exoplayer2/ext/ffmpeg/FfmpegLibrary.java | 2 +- extensions/ffmpeg/src/main/jni/Android.mk | 7 +---- .../ffmpeg/src/main/jni/build_ffmpeg.sh | 2 +- extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc | 29 +++++++++---------- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java index 4639851263..dc72e12e65 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java @@ -33,7 +33,7 @@ public final class FfmpegLibrary { private static final String TAG = "FfmpegLibrary"; private static final LibraryLoader LOADER = - new LibraryLoader("avutil", "avresample", "swresample", "avcodec", "ffmpeg"); + new LibraryLoader("avutil", "swresample", "avcodec", "ffmpeg"); private FfmpegLibrary() {} diff --git a/extensions/ffmpeg/src/main/jni/Android.mk b/extensions/ffmpeg/src/main/jni/Android.mk index 22a4edcdae..bcaf12cd11 100644 --- a/extensions/ffmpeg/src/main/jni/Android.mk +++ b/extensions/ffmpeg/src/main/jni/Android.mk @@ -21,11 +21,6 @@ LOCAL_MODULE := libavcodec LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so include $(PREBUILT_SHARED_LIBRARY) -include $(CLEAR_VARS) -LOCAL_MODULE := libavresample -LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - include $(CLEAR_VARS) LOCAL_MODULE := libswresample LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so @@ -40,6 +35,6 @@ include $(CLEAR_VARS) LOCAL_MODULE := ffmpeg LOCAL_SRC_FILES := ffmpeg_jni.cc LOCAL_C_INCLUDES := ffmpeg -LOCAL_SHARED_LIBRARIES := libavcodec libavresample libswresample libavutil +LOCAL_SHARED_LIBRARIES := libavcodec libswresample libavutil LOCAL_LDLIBS := -Lffmpeg/android-libs/$(TARGET_ARCH_ABI) -llog include $(BUILD_SHARED_LIBRARY) diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh index a76fa0e589..d6db5fc172 100755 --- a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh +++ b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh @@ -32,7 +32,7 @@ COMMON_OPTIONS=" --disable-postproc --disable-avfilter --disable-symver - --enable-avresample + --disable-avresample --enable-swresample " TOOLCHAIN_PREFIX="${NDK_PATH}/toolchains/llvm/prebuilt/${HOST_PLATFORM}/bin" diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc index dcd4560e4a..400039af89 100644 --- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc +++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc @@ -26,10 +26,10 @@ extern "C" { #include #endif #include -#include #include #include #include +#include } #define LOG_TAG "ffmpeg_jni" @@ -289,11 +289,11 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, int sampleCount = frame->nb_samples; int dataSize = av_samples_get_buffer_size(NULL, channelCount, sampleCount, sampleFormat, 1); - AVAudioResampleContext *resampleContext; + SwrContext *resampleContext; if (context->opaque) { - resampleContext = (AVAudioResampleContext *) context->opaque; + resampleContext = (SwrContext *)context->opaque; } else { - resampleContext = avresample_alloc_context(); + resampleContext = swr_alloc(); av_opt_set_int(resampleContext, "in_channel_layout", channelLayout, 0); av_opt_set_int(resampleContext, "out_channel_layout", channelLayout, 0); av_opt_set_int(resampleContext, "in_sample_rate", sampleRate, 0); @@ -302,9 +302,9 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, // The output format is always the requested format. av_opt_set_int(resampleContext, "out_sample_fmt", context->request_sample_fmt, 0); - result = avresample_open(resampleContext); + result = swr_init(resampleContext); if (result < 0) { - logError("avresample_open", result); + logError("swr_init", result); av_frame_free(&frame); return -1; } @@ -312,7 +312,7 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, } int inSampleSize = av_get_bytes_per_sample(sampleFormat); int outSampleSize = av_get_bytes_per_sample(context->request_sample_fmt); - int outSamples = avresample_get_out_samples(resampleContext, sampleCount); + int outSamples = swr_get_out_samples(resampleContext, sampleCount); int bufferOutSize = outSampleSize * channelCount * outSamples; if (outSize + bufferOutSize > outputSize) { LOGE("Output buffer size (%d) too small for output data (%d).", @@ -320,15 +320,14 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, av_frame_free(&frame); return -1; } - result = avresample_convert(resampleContext, &outputBuffer, bufferOutSize, - outSamples, frame->data, frame->linesize[0], - sampleCount); + result = swr_convert(resampleContext, &outputBuffer, bufferOutSize, + (const uint8_t **)frame->data, frame->nb_samples); av_frame_free(&frame); if (result < 0) { - logError("avresample_convert", result); + logError("swr_convert", result); return result; } - int available = avresample_available(resampleContext); + int available = swr_get_out_samples(resampleContext, 0); if (available != 0) { LOGE("Expected no samples remaining after resampling, but found %d.", available); @@ -351,9 +350,9 @@ void releaseContext(AVCodecContext *context) { if (!context) { return; } - AVAudioResampleContext *resampleContext; - if ((resampleContext = (AVAudioResampleContext *) context->opaque)) { - avresample_free(&resampleContext); + SwrContext *swrContext; + if ((swrContext = (SwrContext *)context->opaque)) { + swr_free(&swrContext); context->opaque = NULL; } avcodec_free_context(&context); From 4e6383aeae900fb7504035610020b38dfe3c33ab Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Tue, 10 Mar 2020 10:20:37 +0000 Subject: [PATCH 0156/1052] Merge pull request #7051 from Cizor:dev-v2 PiperOrigin-RevId: 299357049 --- .../google/android/exoplayer2/video/MediaCodecVideoRenderer.java | 1 + 1 file changed, 1 insertion(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 4e72a1b3d7..25dc09be81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1000,6 +1000,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { processOutputFormat(getCodec(), format.width, format.height); } maybeNotifyVideoSizeChanged(); + decoderCounters.renderedOutputBufferCount++; maybeNotifyRenderedFirstFrame(); onProcessedOutputBuffer(presentationTimeUs); } From 40d5db04607e8a91449608a08c8c25fa6ee3eedb Mon Sep 17 00:00:00 2001 From: krocard Date: Mon, 9 Mar 2020 16:43:38 +0000 Subject: [PATCH 0157/1052] Add support for x86_64 for the ffmpeg extension Requested by https://github.com/google/ExoPlayer/issues/7058. Additionally move one of the common option in COMMON_OPTIONS. PiperOrigin-RevId: 299862479 --- RELEASENOTES.md | 1 + extensions/ffmpeg/src/main/jni/build_ffmpeg.sh | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 847746d425..bc862102d1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,7 @@ * Text: Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing, allowing playback to continue even if subtitles fail ([#6885](https://github.com/google/ExoPlayer/issues/6885)). +* FFmpeg extension: Add support for x86_64. ### 2.11.3 (2020-02-19) ### diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh index d6db5fc172..b7d8f0eb7f 100755 --- a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh +++ b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh @@ -34,6 +34,7 @@ COMMON_OPTIONS=" --disable-symver --disable-avresample --enable-swresample + --extra-ldexeflags=-pie " TOOLCHAIN_PREFIX="${NDK_PATH}/toolchains/llvm/prebuilt/${HOST_PLATFORM}/bin" for decoder in "${ENABLED_DECODERS[@]}" @@ -53,7 +54,6 @@ git checkout release/4.2 --strip="${TOOLCHAIN_PREFIX}/arm-linux-androideabi-strip" \ --extra-cflags="-march=armv7-a -mfloat-abi=softfp" \ --extra-ldflags="-Wl,--fix-cortex-a8" \ - --extra-ldexeflags=-pie \ ${COMMON_OPTIONS} make -j4 make install-libs @@ -65,7 +65,6 @@ make clean --cross-prefix="${TOOLCHAIN_PREFIX}/aarch64-linux-android21-" \ --nm="${TOOLCHAIN_PREFIX}/aarch64-linux-android-nm" \ --strip="${TOOLCHAIN_PREFIX}/aarch64-linux-android-strip" \ - --extra-ldexeflags=-pie \ ${COMMON_OPTIONS} make -j4 make install-libs @@ -77,7 +76,18 @@ make clean --cross-prefix="${TOOLCHAIN_PREFIX}/i686-linux-android16-" \ --nm="${TOOLCHAIN_PREFIX}/i686-linux-android-nm" \ --strip="${TOOLCHAIN_PREFIX}/i686-linux-android-strip" \ - --extra-ldexeflags=-pie \ + --disable-asm \ + ${COMMON_OPTIONS} +make -j4 +make install-libs +make clean +./configure \ + --libdir=android-libs/x86_64 \ + --arch=x86_64 \ + --cpu=x86_64 \ + --cross-prefix="${TOOLCHAIN_PREFIX}/x86_64-linux-android16-" \ + --nm="${TOOLCHAIN_PREFIX}/x86_64-linux-android-nm" \ + --strip="${TOOLCHAIN_PREFIX}/x86_64-linux-android-strip" \ --disable-asm \ ${COMMON_OPTIONS} make -j4 From dca68b2198066da57633947c05e1ad7915cba3fa Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 11 Mar 2020 16:27:47 +0000 Subject: [PATCH 0158/1052] Merge pull request #7064 from davibe:enhancement/6907 PiperOrigin-RevId: 300330109 --- RELEASENOTES.md | 2 ++ .../source/dash/DashMediaSource.java | 21 +++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bc862102d1..543a7f83ef 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,8 @@ * Text: Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing, allowing playback to continue even if subtitles fail ([#6885](https://github.com/google/ExoPlayer/issues/6885)). +* DASH: Update the manifest URI to avoid repeated HTTP redirects + ([#6907](https://github.com/google/ExoPlayer/issues/6907)). * FFmpeg extension: Add support for x86_64. ### 2.11.3 (2020-02-19) ### diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index dfcd62b8b1..dcd4b15cae 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -807,15 +807,18 @@ public final class DashMediaSource extends BaseMediaSource { manifestLoadPending &= manifest.dynamic; manifestLoadStartTimestampMs = elapsedRealtimeMs - loadDurationMs; manifestLoadEndTimestampMs = elapsedRealtimeMs; - if (manifest.location != null) { - synchronized (manifestUriLock) { - // This condition checks that replaceManifestUri wasn't called between the start and end of - // this load. If it was, we ignore the manifest location and prefer the manual replacement. - @SuppressWarnings("ReferenceEquality") - boolean isSameUriInstance = loadable.dataSpec.uri == manifestUri; - if (isSameUriInstance) { - manifestUri = manifest.location; - } + + synchronized (manifestUriLock) { + // Checks whether replaceManifestUri(Uri) was called to manually replace the URI between the + // start and end of this load. If it was then isSameUriInstance evaluates to false, and we + // prefer the manual replacement to one derived from the previous request. + @SuppressWarnings("ReferenceEquality") + boolean isSameUriInstance = loadable.dataSpec.uri == manifestUri; + if (isSameUriInstance) { + // Replace the manifest URI with one specified by a manifest Location element (if present), + // or with the final (possibly redirected) URI. This follows the recommendation in + // DASH-IF-IOP 4.3, section 3.2.15.3. See: https://dashif.org/docs/DASH-IF-IOP-v4.3.pdf. + manifestUri = manifest.location != null ? manifest.location : loadable.getUri(); } } From b2849fde3d6cab091fcf10608ef93a26870fc45e Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 11 Mar 2020 16:27:36 +0000 Subject: [PATCH 0159/1052] Merge pull request #7057 from Chimerapps:dash_assetidentifier PiperOrigin-RevId: 300313753 --- RELEASENOTES.md | 6 +++-- .../dash/manifest/DashManifestParser.java | 20 ++++++++++++----- .../source/dash/manifest/Period.java | 22 ++++++++++++++++++- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 543a7f83ef..98de3a6255 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,8 +5,10 @@ * Text: Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing, allowing playback to continue even if subtitles fail ([#6885](https://github.com/google/ExoPlayer/issues/6885)). -* DASH: Update the manifest URI to avoid repeated HTTP redirects - ([#6907](https://github.com/google/ExoPlayer/issues/6907)). +* DASH: + * Update the manifest URI to avoid repeated HTTP redirects + ([#6907](https://github.com/google/ExoPlayer/issues/6907)). + * Parse period `AssetIdentifier` elements. * FFmpeg extension: Add support for x86_64. ### 2.11.3 (2020-02-19) ### diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index b107be4794..95129d68c4 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -222,10 +222,11 @@ public class DashManifestParser extends DefaultHandler protected Pair parsePeriod(XmlPullParser xpp, String baseUrl, long defaultStartMs) throws XmlPullParserException, IOException { - String id = xpp.getAttributeValue(null, "id"); + @Nullable String id = xpp.getAttributeValue(null, "id"); long startMs = parseDuration(xpp, "start", defaultStartMs); long durationMs = parseDuration(xpp, "duration", C.TIME_UNSET); - SegmentBase segmentBase = null; + @Nullable SegmentBase segmentBase = null; + @Nullable Descriptor assetIdentifier = null; List adaptationSets = new ArrayList<>(); List eventStreams = new ArrayList<>(); boolean seenFirstBaseUrl = false; @@ -246,17 +247,24 @@ public class DashManifestParser extends DefaultHandler segmentBase = parseSegmentList(xpp, null, durationMs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { segmentBase = parseSegmentTemplate(xpp, null, Collections.emptyList(), durationMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { + assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); } else { maybeSkipTag(xpp); } } while (!XmlPullParserUtil.isEndTag(xpp, "Period")); - return Pair.create(buildPeriod(id, startMs, adaptationSets, eventStreams), durationMs); + return Pair.create( + buildPeriod(id, startMs, adaptationSets, eventStreams, assetIdentifier), durationMs); } - protected Period buildPeriod(String id, long startMs, List adaptationSets, - List eventStreams) { - return new Period(id, startMs, adaptationSets, eventStreams); + protected Period buildPeriod( + @Nullable String id, + long startMs, + List adaptationSets, + List eventStreams, + @Nullable Descriptor assetIdentifier) { + return new Period(id, startMs, adaptationSets, eventStreams, assetIdentifier); } // AdaptationSet parsing. diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java index 18614ca4b0..b472aed50c 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java @@ -45,13 +45,16 @@ public class Period { */ public final List eventStreams; + /** The asset identifier for this period, if one exists */ + @Nullable public final Descriptor assetIdentifier; + /** * @param id The period identifier. May be null. * @param startMs The start time of the period in milliseconds. * @param adaptationSets The adaptation sets belonging to the period. */ public Period(@Nullable String id, long startMs, List adaptationSets) { - this(id, startMs, adaptationSets, Collections.emptyList()); + this(id, startMs, adaptationSets, Collections.emptyList(), /* assetIdentifier= */ null); } /** @@ -62,10 +65,27 @@ public class Period { */ public Period(@Nullable String id, long startMs, List adaptationSets, List eventStreams) { + this(id, startMs, adaptationSets, eventStreams, /* assetIdentifier= */ null); + } + + /** + * @param id The period identifier. May be null. + * @param startMs The start time of the period in milliseconds. + * @param adaptationSets The adaptation sets belonging to the period. + * @param eventStreams The {@link EventStream}s belonging to the period. + * @param assetIdentifier The asset identifier for this period + */ + public Period( + @Nullable String id, + long startMs, + List adaptationSets, + List eventStreams, + @Nullable Descriptor assetIdentifier) { this.id = id; this.startMs = startMs; this.adaptationSets = Collections.unmodifiableList(adaptationSets); this.eventStreams = Collections.unmodifiableList(eventStreams); + this.assetIdentifier = assetIdentifier; } /** From 4750785f5abe64a3f7c71ca88a4db24a65765c15 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 13 Mar 2020 09:04:00 +0000 Subject: [PATCH 0160/1052] Add option for sensor rotation in 360 playbacks Issue: #6761 PiperOrigin-RevId: 300715682 --- RELEASENOTES.md | 3 ++ .../android/exoplayer2/ui/PlayerView.java | 27 ++++++++++++++ .../ui/spherical/SphericalGLSurfaceView.java | 35 +++++++++++++++---- library/ui/src/main/res/values/attrs.xml | 2 +- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 98de3a6255..c9b986f731 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,9 @@ * Text: Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing, allowing playback to continue even if subtitles fail ([#6885](https://github.com/google/ExoPlayer/issues/6885)). +* UI: Add an option to set whether to use the orientation sensor for rotation + in spherical playbacks + ([#6761](https://github.com/google/ExoPlayer/issues/6761)). * DASH: * Update the manifest URI to avoid repeated HTTP redirects ([#6907](https://github.com/google/ExoPlayer/issues/6907)). diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java index 03168643cf..2eae9c1dde 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java @@ -143,6 +143,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; *

  • Corresponding method: None *
  • Default: {@code surface_view} * + *
  • {@code use_sensor_rotation} - Whether to use the orientation sensor for rotation + * during spherical playbacks (if available). + *
      + *
    • Corresponding method: {@link #setUseSensorRotation(boolean)} + *
    • Default: {@code true} + *
    *
  • {@code shutter_background_color} - The background color of the {@code exo_shutter} * view. * */ int SAMPLE_DATA_PART_ENCRYPTION = 1; - /** Sample supplemental data. */ + /** + * Sample supplemental data. + * + *

    If a sample contains supplemental data, the format of the entire sample data will be: + * + *

      + *
    • If the sample has the {@link C#BUFFER_FLAG_ENCRYPTED} flag set, all encryption + * information. + *
    • (4 bytes) {@code sample_data_size}: The size of the actual sample data, not including + * supplemental data or encryption information. + *
    • ({@code sample_data_size} bytes): The media sample data. + *
    • (remaining bytes) The supplemental data. + *
    + */ int SAMPLE_DATA_PART_SUPPLEMENTAL = 2; /** From a5824f7325b1881cfad61dc2df19eabfca8c9d23 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 13 May 2020 11:58:56 +0100 Subject: [PATCH 0240/1052] Restrict Decoder generic exception type to extend DecoderException This is useful for merging the FFmpeg video support pull request, since it allows a Decoder base class implementation to catch DecoderException rather than forcing it to catch Exception (i.e., everything). Note that unfortunately, Java doesn't allow catching of a generic type (i.e., you cannot "catch (E e)") due to type erasure. PiperOrigin-RevId: 311300719 --- .../android/exoplayer2/decoder/Decoder.java | 2 +- .../exoplayer2/decoder/DecoderException.java | 26 +++++++++++++++---- .../exoplayer2/decoder/SimpleDecoder.java | 2 +- .../text/SubtitleDecoderException.java | 18 ++++++------- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java index 4552d190c3..c94eb2c38a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java @@ -24,7 +24,7 @@ import androidx.annotation.Nullable; * @param The type of buffer output from the decoder. * @param The type of exception thrown from the decoder. */ -public interface Decoder { +public interface Decoder { /** * Returns the name of the decoder. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java index c07e646f09..0af3313ea3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java @@ -15,20 +15,36 @@ */ package com.google.android.exoplayer2.decoder; +import androidx.annotation.Nullable; + /** Thrown when a {@link Decoder} error occurs. */ public class DecoderException extends Exception { - /** @param message The detail message for this exception. */ + /** + * Creates an instance. + * + * @param message The detail message for this exception. + */ public DecoderException(String message) { super(message); } /** - * @param message The detail message for this exception. - * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). - * A null value is permitted, and indicates that the cause is nonexistent or unknown. + * Creates an instance. + * + * @param cause The cause of this exception, or {@code null}. */ - public DecoderException(String message, Throwable cause) { + public DecoderException(@Nullable Throwable cause) { + super(cause); + } + + /** + * Creates an instance. + * + * @param message The detail message for this exception. + * @param cause The cause of this exception, or {@code null}. + */ + public DecoderException(String message, @Nullable Throwable cause) { super(message, cause); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java index 8f660c4c24..beaea6cf37 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java @@ -27,7 +27,7 @@ import java.util.ArrayDeque; */ @SuppressWarnings("UngroupedOverloads") public abstract class SimpleDecoder< - I extends DecoderInputBuffer, O extends OutputBuffer, E extends Exception> + I extends DecoderInputBuffer, O extends OutputBuffer, E extends DecoderException> implements Decoder { private final Thread decodeThread; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java index b235706370..7de577f18c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java @@ -15,10 +15,11 @@ */ package com.google.android.exoplayer2.text; -/** - * Thrown when an error occurs decoding subtitle data. - */ -public class SubtitleDecoderException extends Exception { +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.decoder.DecoderException; + +/** Thrown when an error occurs decoding subtitle data. */ +public class SubtitleDecoderException extends DecoderException { /** * @param message The detail message for this exception. @@ -27,17 +28,16 @@ public class SubtitleDecoderException extends Exception { super(message); } - /** @param cause The cause of this exception. */ - public SubtitleDecoderException(Exception cause) { + /** @param cause The cause of this exception, or {@code null}. */ + public SubtitleDecoderException(@Nullable Throwable cause) { super(cause); } /** * @param message The detail message for this exception. - * @param cause The cause of this exception. + * @param cause The cause of this exception, or {@code null}. */ - public SubtitleDecoderException(String message, Throwable cause) { + public SubtitleDecoderException(String message, @Nullable Throwable cause) { super(message, cause); } - } From 6e47819be4d14b32b5a613c1231d3d884f723444 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 13 May 2020 13:59:23 +0100 Subject: [PATCH 0241/1052] Minor copybara fixes - Remove proguard-rules.txt symlink - Guard against classes annotated with @ClosedSource but not excluded PiperOrigin-RevId: 311312671 --- library/extractor/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index 26b38705ee..e12eb009eb 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -40,10 +40,10 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation project(modulePrefix + 'library-common') compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion - implementation project(modulePrefix + 'library-common') testImplementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') testImplementation project(modulePrefix + 'testdata') From 1c3c7c58abadb45e6761e4d75a845ec4d63eede8 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 13 May 2020 14:00:10 +0100 Subject: [PATCH 0242/1052] Rename SubtitleTextView to CanvasSubtitleOutput It displays images too, and in fact it's used exclusively to display images in SubtitleWebView. It also doesn't use a TextView - so all round a slightly confusing name. Also rename SubtitleWebView to WebViewSubtitleOutput to match the same pattern. PiperOrigin-RevId: 311312758 --- ...extView.java => CanvasSubtitleOutput.java} | 8 +- .../android/exoplayer2/ui/HtmlUtils.java | 2 +- .../android/exoplayer2/ui/SubtitleView.java | 91 ++++++++++--------- ...ebView.java => WebViewSubtitleOutput.java} | 18 ++-- 4 files changed, 63 insertions(+), 56 deletions(-) rename library/ui/src/main/java/com/google/android/exoplayer2/ui/{SubtitleTextView.java => CanvasSubtitleOutput.java} (94%) rename library/ui/src/main/java/com/google/android/exoplayer2/ui/{SubtitleWebView.java => WebViewSubtitleOutput.java} (94%) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleTextView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java similarity index 94% rename from library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleTextView.java rename to library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java index 0e5b4a3ab2..19ad36c29d 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleTextView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java @@ -30,10 +30,10 @@ import java.util.Collections; import java.util.List; /** - * A {@link SubtitleView.Output} that uses Android's native text tooling via {@link + * A {@link SubtitleView.Output} that uses Android's native layout framework via {@link * SubtitlePainter}. */ -/* package */ final class SubtitleTextView extends View implements SubtitleView.Output { +/* package */ final class CanvasSubtitleOutput extends View implements SubtitleView.Output { private final List painters; @@ -43,11 +43,11 @@ import java.util.List; private CaptionStyleCompat style; private float bottomPaddingFraction; - public SubtitleTextView(Context context) { + public CanvasSubtitleOutput(Context context) { this(context, /* attrs= */ null); } - public SubtitleTextView(Context context, @Nullable AttributeSet attrs) { + public CanvasSubtitleOutput(Context context, @Nullable AttributeSet attrs) { super(context, attrs); painters = new ArrayList<>(); cues = Collections.emptyList(); diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java index 0edee287a9..9e3ae65eee 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java @@ -20,7 +20,7 @@ import androidx.annotation.ColorInt; import com.google.android.exoplayer2.util.Util; /** - * Utility methods for generating HTML and CSS for use with {@link SubtitleWebView} and {@link + * Utility methods for generating HTML and CSS for use with {@link WebViewSubtitleOutput} and {@link * SpannedToHtmlConverter}. */ /* package */ final class HtmlUtils { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index 24f9f6b3a2..68c48d1b34 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -20,6 +20,7 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.content.Context; import android.content.res.Resources; +import android.graphics.Canvas; import android.text.SpannableString; import android.text.Spanned; import android.text.style.AbsoluteSizeSpan; @@ -28,6 +29,7 @@ import android.util.AttributeSet; import android.util.TypedValue; import android.view.View; import android.view.accessibility.CaptioningManager; +import android.webkit.WebView; import android.widget.FrameLayout; import androidx.annotation.Dimension; import androidx.annotation.IntDef; @@ -46,6 +48,37 @@ import java.util.List; /** A view for displaying subtitle {@link Cue}s. */ public final class SubtitleView extends FrameLayout implements TextOutput { + /** + * An output for displaying subtitles. + * + *

    Implementations of this also need to extend {@link View} in order to be attached to the + * Android view hierarchy. + */ + /* package */ interface Output { + + /** + * Updates the list of cues displayed. + * + * @param cues The cues to display. + * @param style A {@link CaptionStyleCompat} to use for styling unset properties of cues. + * @param defaultTextSize The default font size to apply when {@link Cue#textSize} is {@link + * Cue#DIMEN_UNSET}. + * @param defaultTextSizeType The type of {@code defaultTextSize}. + * @param bottomPaddingFraction The bottom padding to apply when {@link Cue#line} is {@link + * Cue#DIMEN_UNSET}, as a fraction of the view's remaining height after its top and bottom + * padding have been subtracted. + * @see #setStyle(CaptionStyleCompat) + * @see #setTextSize(int, float) + * @see #setBottomPaddingFraction(float) + */ + void update( + List cues, + CaptionStyleCompat style, + float defaultTextSize, + @Cue.TextSizeType int defaultTextSizeType, + float bottomPaddingFraction); + } + /** * The default fractional text size. * @@ -61,17 +94,14 @@ public final class SubtitleView extends FrameLayout implements TextOutput { */ public static final float DEFAULT_BOTTOM_PADDING_FRACTION = 0.08f; - /** - * Indicates a {@link SubtitleTextView} should be used to display subtitles. This is the default. - */ - public static final int VIEW_TYPE_TEXT = 1; + /** Indicates subtitles should be displayed using a {@link Canvas}. This is the default. */ + public static final int VIEW_TYPE_CANVAS = 1; /** - * Indicates a {@link SubtitleWebView} should be used to display subtitles. + * Indicates subtitles should be displayed using a {@link WebView}. * - *

    This will instantiate a {@link android.webkit.WebView} and use CSS and HTML styling to - * render the subtitles. This supports some additional styling features beyond those supported by - * {@link SubtitleTextView} such as vertical text. + *

    This will use CSS and HTML styling to render the subtitles. This supports some additional + * styling features beyond those supported by {@link #VIEW_TYPE_CANVAS} such as vertical text. */ public static final int VIEW_TYPE_WEB = 2; @@ -81,13 +111,13 @@ public final class SubtitleView extends FrameLayout implements TextOutput { *

    One of: * *

      - *
    • {@link #VIEW_TYPE_TEXT} + *
    • {@link #VIEW_TYPE_CANVAS} *
    • {@link #VIEW_TYPE_WEB} *
    */ @Documented @Retention(SOURCE) - @IntDef({VIEW_TYPE_TEXT, VIEW_TYPE_WEB}) + @IntDef({VIEW_TYPE_CANVAS, VIEW_TYPE_WEB}) public @interface ViewType {} private List cues; @@ -116,11 +146,11 @@ public final class SubtitleView extends FrameLayout implements TextOutput { applyEmbeddedStyles = true; applyEmbeddedFontSizes = true; - SubtitleTextView subtitleTextView = new SubtitleTextView(context, attrs); - output = subtitleTextView; - innerSubtitleView = subtitleTextView; + CanvasSubtitleOutput canvasSubtitleOutput = new CanvasSubtitleOutput(context, attrs); + output = canvasSubtitleOutput; + innerSubtitleView = canvasSubtitleOutput; addView(innerSubtitleView); - viewType = VIEW_TYPE_TEXT; + viewType = VIEW_TYPE_CANVAS; } @Override @@ -151,11 +181,11 @@ public final class SubtitleView extends FrameLayout implements TextOutput { return; } switch (viewType) { - case VIEW_TYPE_TEXT: - setView(new SubtitleTextView(getContext())); + case VIEW_TYPE_CANVAS: + setView(new CanvasSubtitleOutput(getContext())); break; case VIEW_TYPE_WEB: - setView(new SubtitleWebView(getContext())); + setView(new WebViewSubtitleOutput(getContext())); break; default: throw new IllegalArgumentException(); @@ -165,8 +195,8 @@ public final class SubtitleView extends FrameLayout implements TextOutput { private void setView(T view) { removeView(innerSubtitleView); - if (innerSubtitleView instanceof SubtitleWebView) { - ((SubtitleWebView) innerSubtitleView).destroy(); + if (innerSubtitleView instanceof WebViewSubtitleOutput) { + ((WebViewSubtitleOutput) innerSubtitleView).destroy(); } innerSubtitleView = view; output = view; @@ -383,28 +413,5 @@ public final class SubtitleView extends FrameLayout implements TextOutput { return cue; } - /* package */ interface Output { - /** - * Updates the list of cues displayed. - * - * @param cues The cues to display. - * @param style A {@link CaptionStyleCompat} to use for styling unset properties of cues. - * @param defaultTextSize The default font size to apply when {@link Cue#textSize} is {@link - * Cue#DIMEN_UNSET}. - * @param defaultTextSizeType The type of {@code defaultTextSize}. - * @param bottomPaddingFraction The bottom padding to apply when {@link Cue#line} is {@link - * Cue#DIMEN_UNSET}, as a fraction of the view's remaining height after its top and bottom - * padding have been subtracted. - * @see #setStyle(CaptionStyleCompat) - * @see #setTextSize(int, float) - * @see #setBottomPaddingFraction(float) - */ - void update( - List cues, - CaptionStyleCompat style, - float defaultTextSize, - @Cue.TextSizeType int defaultTextSizeType, - float bottomPaddingFraction); - } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java similarity index 94% rename from library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java rename to library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index c88de9ba4a..12fd726527 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -43,26 +43,26 @@ import java.util.List; *

    NOTE: This is currently extremely experimental and doesn't support most {@link Cue} styling * properties. */ -/* package */ final class SubtitleWebView extends FrameLayout implements SubtitleView.Output { +/* package */ final class WebViewSubtitleOutput extends FrameLayout implements SubtitleView.Output { /** - * A {@link SubtitleTextView} used for displaying bitmap cues. + * A {@link CanvasSubtitleOutput} used for displaying bitmap cues. * *

    There's no advantage to displaying bitmap cues in a {@link WebView}, so we re-use the * existing logic. */ - private final SubtitleTextView subtitleTextView; + private final CanvasSubtitleOutput canvasSubtitleOutput; private final WebView webView; - public SubtitleWebView(Context context) { + public WebViewSubtitleOutput(Context context) { this(context, null); } - public SubtitleWebView(Context context, @Nullable AttributeSet attrs) { + public WebViewSubtitleOutput(Context context, @Nullable AttributeSet attrs) { super(context, attrs); - subtitleTextView = new SubtitleTextView(context, attrs); + canvasSubtitleOutput = new CanvasSubtitleOutput(context, attrs); webView = new WebView(context, attrs) { @Override @@ -81,7 +81,7 @@ import java.util.List; }; webView.setBackgroundColor(Color.TRANSPARENT); - addView(subtitleTextView); + addView(canvasSubtitleOutput); addView(webView); } @@ -102,8 +102,8 @@ import java.util.List; textCues.add(cue); } } - subtitleTextView.update(bitmapCues, style, textSize, textSizeType, bottomPaddingFraction); - // Invalidate to trigger subtitleTextView to draw. + canvasSubtitleOutput.update(bitmapCues, style, textSize, textSizeType, bottomPaddingFraction); + // Invalidate to trigger canvasSubtitleOutput to draw. invalidate(); updateWebView(textCues, style, textSize, textSizeType, bottomPaddingFraction); } From 025a2c2b6240b7391ec8965c33ea066ed8b9563d Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 13 May 2020 15:28:21 +0100 Subject: [PATCH 0243/1052] Make MediaDrmCallback surface more error information Issue:#7309 PiperOrigin-RevId: 311324242 --- .../exoplayer2/drm/HttpMediaDrmCallback.java | 86 ++++++++++++------- .../exoplayer2/drm/LocalMediaDrmCallback.java | 5 +- .../exoplayer2/drm/MediaDrmCallback.java | 9 +- .../drm/MediaDrmCallbackException.java | 63 ++++++++++++++ 4 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java index 655a8d92d8..7ab90b023e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java @@ -24,9 +24,9 @@ import com.google.android.exoplayer2.upstream.DataSourceInputStream; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; +import com.google.android.exoplayer2.upstream.StatsDataSource; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; -import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -104,14 +104,19 @@ public final class HttpMediaDrmCallback implements MediaDrmCallback { } @Override - public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws IOException { + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) + throws MediaDrmCallbackException { String url = request.getDefaultUrl() + "&signedRequest=" + Util.fromUtf8Bytes(request.getData()); - return executePost(dataSourceFactory, url, /* httpBody= */ null, /* requestProperties= */ null); + return executePost( + dataSourceFactory, + url, + /* httpBody= */ null, + /* requestProperties= */ Collections.emptyMap()); } @Override - public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception { + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws MediaDrmCallbackException { String url = request.getLicenseServerUrl(); if (forceDefaultLicenseUrl || TextUtils.isEmpty(url)) { url = defaultLicenseUrl; @@ -136,41 +141,56 @@ public final class HttpMediaDrmCallback implements MediaDrmCallback { HttpDataSource.Factory dataSourceFactory, String url, @Nullable byte[] httpBody, - @Nullable Map requestProperties) - throws IOException { - HttpDataSource dataSource = dataSourceFactory.createDataSource(); + Map requestProperties) + throws MediaDrmCallbackException { + StatsDataSource dataSource = new StatsDataSource(dataSourceFactory.createDataSource()); int manualRedirectCount = 0; - while (true) { - DataSpec dataSpec = - new DataSpec.Builder() - .setUri(url) - .setHttpRequestHeaders( - requestProperties != null ? requestProperties : Collections.emptyMap()) - .setHttpMethod(DataSpec.HTTP_METHOD_POST) - .setHttpBody(httpBody) - .setFlags(DataSpec.FLAG_ALLOW_GZIP) - .build(); - DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); - try { - return Util.toByteArray(inputStream); - } catch (InvalidResponseCodeException e) { - // For POST requests, the underlying network stack will not normally follow 307 or 308 - // redirects automatically. Do so manually here. - boolean manuallyRedirect = - (e.responseCode == 307 || e.responseCode == 308) - && manualRedirectCount++ < MAX_MANUAL_REDIRECTS; - @Nullable String redirectUrl = manuallyRedirect ? getRedirectUrl(e) : null; - if (redirectUrl == null) { - throw e; + DataSpec dataSpec = + new DataSpec.Builder() + .setUri(url) + .setHttpRequestHeaders(requestProperties) + .setHttpMethod(DataSpec.HTTP_METHOD_POST) + .setHttpBody(httpBody) + .setFlags(DataSpec.FLAG_ALLOW_GZIP) + .build(); + DataSpec originalDataSpec = dataSpec; + try { + while (true) { + DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + try { + return Util.toByteArray(inputStream); + } catch (InvalidResponseCodeException e) { + @Nullable String redirectUrl = getRedirectUrl(e, manualRedirectCount); + if (redirectUrl == null) { + throw e; + } + manualRedirectCount++; + dataSpec = dataSpec.buildUpon().setUri(redirectUrl).build(); + } finally { + Util.closeQuietly(inputStream); } - url = redirectUrl; - } finally { - Util.closeQuietly(inputStream); } + } catch (Exception e) { + throw new MediaDrmCallbackException( + originalDataSpec, + Assertions.checkNotNull(dataSource.getLastOpenedUri()), + dataSource.getResponseHeaders(), + dataSource.getBytesRead(), + /* cause= */ e); } } - private static @Nullable String getRedirectUrl(InvalidResponseCodeException exception) { + @Nullable + private static String getRedirectUrl( + InvalidResponseCodeException exception, int manualRedirectCount) { + // For POST requests, the underlying network stack will not normally follow 307 or 308 + // redirects automatically. Do so manually here. + boolean manuallyRedirect = + (exception.responseCode == 307 || exception.responseCode == 308) + && manualRedirectCount < MAX_MANUAL_REDIRECTS; + if (!manuallyRedirect) { + return null; + } Map> headerFields = exception.headerFields; if (headerFields != null) { @Nullable List locationHeaders = headerFields.get("Location"); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java index 7b9aeca30a..d141b6c4c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.drm; import com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; import com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; import com.google.android.exoplayer2.util.Assertions; -import java.io.IOException; import java.util.UUID; /** @@ -39,12 +38,12 @@ public final class LocalMediaDrmCallback implements MediaDrmCallback { } @Override - public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws IOException { + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) { throw new UnsupportedOperationException(); } @Override - public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception { + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) { return keyResponse; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java index 5b0ed04f81..14b817e713 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java @@ -30,9 +30,10 @@ public interface MediaDrmCallback { * @param uuid The UUID of the content protection scheme. * @param request The request. * @return The response data. - * @throws Exception If an error occurred executing the request. + * @throws MediaDrmCallbackException If an error occurred executing the request. */ - byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws Exception; + byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) + throws MediaDrmCallbackException; /** * Executes a key request. @@ -40,7 +41,7 @@ public interface MediaDrmCallback { * @param uuid The UUID of the content protection scheme. * @param request The request. * @return The response data. - * @throws Exception If an error occurred executing the request. + * @throws MediaDrmCallbackException If an error occurred executing the request. */ - byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception; + byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws MediaDrmCallbackException; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java new file mode 100644 index 0000000000..37b2e03504 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.drm; + +import android.net.Uri; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Thrown when an error occurs while executing a DRM {@link MediaDrmCallback#executeKeyRequest key} + * or {@link MediaDrmCallback#executeProvisionRequest provisioning} request. + */ +public final class MediaDrmCallbackException extends IOException { + + /** The {@link DataSpec} associated with the request. */ + public final DataSpec dataSpec; + /** + * The {@link Uri} after redirections, or {@link #dataSpec dataSpec.uri} if no redirection + * occurred. + */ + public final Uri uriAfterRedirects; + /** The HTTP request headers included in the response. */ + public final Map> responseHeaders; + /** The number of bytes obtained from the server. */ + public final long bytesLoaded; + + /** + * Creates a new instance with the given values. + * + * @param dataSpec See {@link #dataSpec}. + * @param uriAfterRedirects See {@link #uriAfterRedirects}. + * @param responseHeaders See {@link #responseHeaders}. + * @param bytesLoaded See {@link #bytesLoaded}. + * @param cause The cause of the exception. + */ + public MediaDrmCallbackException( + DataSpec dataSpec, + Uri uriAfterRedirects, + Map> responseHeaders, + long bytesLoaded, + Throwable cause) { + super(cause); + this.dataSpec = dataSpec; + this.uriAfterRedirects = uriAfterRedirects; + this.responseHeaders = responseHeaders; + this.bytesLoaded = bytesLoaded; + } +} From ba5871dd0ec643b832ccc34787e0929993c8baf8 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 13 May 2020 15:40:43 +0100 Subject: [PATCH 0244/1052] Add support for CaptionStyle.edgeStyle to WebViewSubtitleOutput This is the last piece to bring feature parity between WebViewSubtitleOutput and CanvasSubtitleOutput. PiperOrigin-RevId: 311325749 --- .../exoplayer2/ui/WebViewSubtitleOutput.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index 12fd726527..1df9d754ac 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -39,9 +39,6 @@ import java.util.List; * *

    This is useful for subtitle styling not supported by Android's native text libraries such as * vertical text. - * - *

    NOTE: This is currently extremely experimental and doesn't support most {@link Cue} styling - * properties. */ /* package */ final class WebViewSubtitleOutput extends FrameLayout implements SubtitleView.Output { @@ -136,9 +133,11 @@ import java.util.List; + "right:0;" + "color:%s;" + "font-size:%s;" + + "text-shadow:%s;" + "'>", HtmlUtils.toCssRgba(style.foregroundColor), - convertTextSizeToCss(defaultTextSizeType, defaultTextSize))); + convertTextSizeToCss(defaultTextSizeType, defaultTextSize), + convertCaptionStyleToCssTextShadow(style))); String backgroundColorCss = HtmlUtils.toCssRgba(style.backgroundColor); @@ -282,6 +281,28 @@ import java.util.List; return Util.formatInvariant("%.2fpx", sizeDp); } + private static String convertCaptionStyleToCssTextShadow(CaptionStyleCompat style) { + switch (style.edgeType) { + case CaptionStyleCompat.EDGE_TYPE_DEPRESSED: + return Util.formatInvariant( + "-0.05em -0.05em 0.15em %s", HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW: + return Util.formatInvariant("0.1em 0.12em 0.15em %s", HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_OUTLINE: + // -webkit-text-stroke makes the underlying text appear too narrow, so we 'fake' an edge + // outline using 4 text-shadows each offset by 1px in different directions. + return Util.formatInvariant( + "1px 1px 0 %1$s, 1px -1px 0 %1$s, -1px 1px 0 %1$s, -1px -1px 0 %1$s", + HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_RAISED: + return Util.formatInvariant( + "0.06em 0.08em 0.15em %s", HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_NONE: + default: + return "unset"; + } + } + private static String convertVerticalTypeToCss(@Cue.VerticalType int verticalType) { switch (verticalType) { case Cue.VERTICAL_TYPE_LR: From 7b552d7786e773cd8c82533867bbe40d3e86bc04 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 14 May 2020 09:08:42 +0100 Subject: [PATCH 0245/1052] Move common init steps into SimpleExoPlayer builder. Some player setup steps that are likely to be only done once should be moved into the Builder so that player setup can use a consistent style (builder vs setters). This also prevents some threading warning issues when the player is built on a background thread (e.g. for dependency injection frameworks) and setters can't be used due to threading restrictions. PiperOrigin-RevId: 311487224 --- RELEASENOTES.md | 2 + .../google/android/exoplayer2/ExoPlayer.java | 66 ++++-- .../android/exoplayer2/ExoPlayerFactory.java | 2 + .../android/exoplayer2/ExoPlayerImpl.java | 9 +- .../exoplayer2/ExoPlayerImplInternal.java | 5 +- .../android/exoplayer2/SimpleExoPlayer.java | 205 ++++++++++++++++-- 6 files changed, 244 insertions(+), 45 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1098e1155e..1defae4356 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -7,6 +7,8 @@ * Added `TextComponent.getCurrentCues` because the current cues are no longer forwarded to a new `TextOutput` in `SimpleExoPlayer` automatically. + * Add additional options to `SimpleExoPlayer.Builder` that were previously + only accessible via setters. * Add opt-in to verify correct thread usage with `SimpleExoPlayer.setThrowsWhenUsingWrongThread(true)` ([#4463](https://github.com/google/ExoPlayer/issues/4463)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index d779037817..b4cd9a399d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -146,6 +146,8 @@ public interface ExoPlayer extends Player { private Looper looper; @Nullable private AnalyticsCollector analyticsCollector; private boolean useLazyPreparation; + private SeekParameters seekParameters; + private boolean pauseAtEndOfMediaItems; private boolean buildCalled; private long releaseTimeoutMs; @@ -166,6 +168,8 @@ public interface ExoPlayer extends Player { * Looper} *

  • {@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} *
  • {@code useLazyPreparation}: {@code true} + *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} + *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} * * @@ -178,50 +182,37 @@ public interface ExoPlayer extends Player { new DefaultTrackSelector(context), DefaultMediaSourceFactory.newInstance(context), new DefaultLoadControl(), - DefaultBandwidthMeter.getSingletonInstance(context), - Util.getLooper(), - /* analyticsCollector= */ null, - /* useLazyPreparation= */ true, - Clock.DEFAULT); + DefaultBandwidthMeter.getSingletonInstance(context)); } /** * Creates a builder with the specified custom components. * - *

    Note that this constructor is only useful if you try to ensure that ExoPlayer's default - * components can be removed by ProGuard or R8. For most components except renderers, there is - * only a marginal benefit of doing that. + *

    Note that this constructor is only useful to try and ensure that ExoPlayer's default + * components can be removed by ProGuard or R8. * * @param renderers The {@link Renderer Renderers} to be used by the player. * @param trackSelector A {@link TrackSelector}. * @param mediaSourceFactory A {@link MediaSourceFactory}. * @param loadControl A {@link LoadControl}. * @param bandwidthMeter A {@link BandwidthMeter}. - * @param looper A {@link Looper} that must be used for all calls to the player. - * @param analyticsCollector An {@link AnalyticsCollector}. - * @param useLazyPreparation Whether media sources should be initialized lazily. - * @param clock A {@link Clock}. Should always be {@link Clock#DEFAULT}. */ public Builder( Renderer[] renderers, TrackSelector trackSelector, MediaSourceFactory mediaSourceFactory, LoadControl loadControl, - BandwidthMeter bandwidthMeter, - Looper looper, - @Nullable AnalyticsCollector analyticsCollector, - boolean useLazyPreparation, - Clock clock) { + BandwidthMeter bandwidthMeter) { Assertions.checkArgument(renderers.length > 0); this.renderers = renderers; this.trackSelector = trackSelector; this.mediaSourceFactory = mediaSourceFactory; this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; - this.looper = looper; - this.analyticsCollector = analyticsCollector; - this.useLazyPreparation = useLazyPreparation; - this.clock = clock; + looper = Util.getLooper(); + useLazyPreparation = true; + seekParameters = SeekParameters.DEFAULT; + clock = Clock.DEFAULT; } /** @@ -347,6 +338,37 @@ public interface ExoPlayer extends Player { return this; } + /** + * Sets the parameters that control how seek operations are performed. + * + * @param seekParameters The {@link SeekParameters}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setSeekParameters(SeekParameters seekParameters) { + Assertions.checkState(!buildCalled); + this.seekParameters = seekParameters; + return this; + } + + /** + * Sets whether to pause playback at the end of each media item. + * + *

    This means the player will pause at the end of each window in the current {@link + * #getCurrentTimeline() timeline}. Listeners will be informed by a call to {@link + * Player.EventListener#onPlayWhenReadyChanged(boolean, int)} with the reason {@link + * Player#PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM} when this happens. + * + * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) { + Assertions.checkState(!buildCalled); + this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; + return this; + } + /** * Sets the {@link Clock} that will be used by the player. Should only be set for testing * purposes. @@ -379,6 +401,8 @@ public interface ExoPlayer extends Player { bandwidthMeter, analyticsCollector, useLazyPreparation, + seekParameters, + pauseAtEndOfMediaItems, clock, looper); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index 32d00d90c1..2c07593aaa 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -258,6 +258,8 @@ public final class ExoPlayerFactory { bandwidthMeter, /* analyticsCollector= */ null, /* useLazyPreparation= */ true, + SeekParameters.DEFAULT, + /* pauseAtEndOfMediaItems= */ false, Clock.DEFAULT, applicationLooper); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 543e72b2dd..26357a18dc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -109,6 +109,8 @@ import java.util.concurrent.TimeoutException; * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest * loads and other initial preparation steps happen immediately. If true, these initial * preparations are triggered only when the player starts buffering the media. + * @param seekParameters The {@link SeekParameters}. + * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. * @param clock The {@link Clock}. * @param applicationLooper The {@link Looper} that must be used for all calls to the player and * which is used to call listeners on. @@ -122,6 +124,8 @@ import java.util.concurrent.TimeoutException; BandwidthMeter bandwidthMeter, @Nullable AnalyticsCollector analyticsCollector, boolean useLazyPreparation, + SeekParameters seekParameters, + boolean pauseAtEndOfMediaItems, Clock clock, Looper applicationLooper) { Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" @@ -131,6 +135,8 @@ import java.util.concurrent.TimeoutException; this.trackSelector = checkNotNull(trackSelector); this.mediaSourceFactory = mediaSourceFactory; this.useLazyPreparation = useLazyPreparation; + this.seekParameters = seekParameters; + this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; repeatMode = Player.REPEAT_MODE_OFF; listeners = new CopyOnWriteArrayList<>(); mediaSourceHolders = new ArrayList<>(); @@ -142,7 +148,6 @@ import java.util.concurrent.TimeoutException; null); period = new Timeline.Period(); playbackSpeed = Player.DEFAULT_PLAYBACK_SPEED; - seekParameters = SeekParameters.DEFAULT; maskingWindowIndex = C.INDEX_UNSET; applicationHandler = new Handler(applicationLooper) { @@ -166,6 +171,8 @@ import java.util.concurrent.TimeoutException; repeatMode, shuffleModeEnabled, analyticsCollector, + seekParameters, + pauseAtEndOfMediaItems, applicationHandler, clock); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 96e8f3d8ac..53c8a5d080 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -146,6 +146,8 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, @Nullable AnalyticsCollector analyticsCollector, + SeekParameters seekParameters, + boolean pauseAtEndOfWindow, Handler eventHandler, Clock clock) { this.renderers = renderers; @@ -155,6 +157,8 @@ import java.util.concurrent.atomic.AtomicBoolean; this.bandwidthMeter = bandwidthMeter; this.repeatMode = repeatMode; this.shuffleModeEnabled = shuffleModeEnabled; + this.seekParameters = seekParameters; + this.pauseAtEndOfWindow = pauseAtEndOfWindow; this.eventHandler = eventHandler; this.clock = clock; this.queue = new MediaPeriodQueue(); @@ -162,7 +166,6 @@ import java.util.concurrent.atomic.AtomicBoolean; backBufferDurationUs = loadControl.getBackBufferDurationUs(); retainBackBufferFromKeyframe = loadControl.retainBackBufferFromKeyframe(); - seekParameters = SeekParameters.DEFAULT; playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); playbackInfoUpdate = new PlaybackInfoUpdate(playbackInfo); rendererCapabilities = new RendererCapabilities[renderers.length]; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 338df091b8..d1f0cfc798 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -99,7 +99,16 @@ public class SimpleExoPlayer extends BasePlayer private BandwidthMeter bandwidthMeter; private AnalyticsCollector analyticsCollector; private Looper looper; + @Nullable private PriorityTaskManager priorityTaskManager; + private AudioAttributes audioAttributes; + private boolean handleAudioFocus; + @C.WakeMode private int wakeMode; + private boolean handleAudioBecomingNoisy; + private boolean skipSilenceEnabled; + @Renderer.VideoScalingMode private int videoScalingMode; private boolean useLazyPreparation; + private SeekParameters seekParameters; + private boolean pauseAtEndOfMediaItems; private boolean throwWhenStuckBuffering; private boolean buildCalled; @@ -122,7 +131,15 @@ public class SimpleExoPlayer extends BasePlayer * Looper} of the application's main thread if the current thread doesn't have a {@link * Looper} *

  • {@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} + *
  • {@link PriorityTaskManager}: {@code null} (not used) + *
  • {@link AudioAttributes}: {@link AudioAttributes#DEFAULT}, not handling audio focus + *
  • {@link C.WakeMode}: {@link C#WAKE_MODE_NONE} + *
  • {@code handleAudioBecomingNoisy}: {@code true} + *
  • {@code skipSilenceEnabled}: {@code false} + *
  • {@link Renderer.VideoScalingMode}: {@link Renderer#VIDEO_SCALING_MODE_DEFAULT} *
  • {@code useLazyPreparation}: {@code true} + *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} + *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} * * @@ -149,18 +166,14 @@ public class SimpleExoPlayer extends BasePlayer DefaultMediaSourceFactory.newInstance(context), new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), - Util.getLooper(), - new AnalyticsCollector(Clock.DEFAULT), - /* useLazyPreparation= */ true, - Clock.DEFAULT); + new AnalyticsCollector(Clock.DEFAULT)); } /** * Creates a builder with the specified custom components. * - *

    Note that this constructor is only useful if you try to ensure that ExoPlayer's default - * components can be removed by ProGuard or R8. For most components except renderers, there is - * only a marginal benefit of doing that. + *

    Note that this constructor is only useful to try and ensure that ExoPlayer's default + * components can be removed by ProGuard or R8. * * @param context A {@link Context}. * @param renderersFactory A factory for creating {@link Renderer Renderers} to be used by the @@ -169,12 +182,7 @@ public class SimpleExoPlayer extends BasePlayer * @param mediaSourceFactory A {@link MediaSourceFactory}. * @param loadControl A {@link LoadControl}. * @param bandwidthMeter A {@link BandwidthMeter}. - * @param looper A {@link Looper} that must be used for all calls to the player. * @param analyticsCollector An {@link AnalyticsCollector}. - * @param useLazyPreparation Whether playlist items should be prepared lazily. If false, all - * initial preparation steps (e.g., manifest loads) happen immediately. If true, these - * initial preparations are triggered only when the player starts buffering the media. - * @param clock A {@link Clock}. Should always be {@link Clock#DEFAULT}. */ public Builder( Context context, @@ -183,20 +191,21 @@ public class SimpleExoPlayer extends BasePlayer MediaSourceFactory mediaSourceFactory, LoadControl loadControl, BandwidthMeter bandwidthMeter, - Looper looper, - AnalyticsCollector analyticsCollector, - boolean useLazyPreparation, - Clock clock) { + AnalyticsCollector analyticsCollector) { this.context = context; this.renderersFactory = renderersFactory; this.trackSelector = trackSelector; this.mediaSourceFactory = mediaSourceFactory; this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; - this.looper = looper; this.analyticsCollector = analyticsCollector; - this.useLazyPreparation = useLazyPreparation; - this.clock = clock; + looper = Util.getLooper(); + audioAttributes = AudioAttributes.DEFAULT; + wakeMode = C.WAKE_MODE_NONE; + videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; + useLazyPreparation = true; + seekParameters = SeekParameters.DEFAULT; + clock = Clock.DEFAULT; } /** @@ -278,6 +287,111 @@ public class SimpleExoPlayer extends BasePlayer return this; } + /** + * Sets an {@link PriorityTaskManager} that will be used by the player. + * + *

    The priority {@link C#PRIORITY_PLAYBACK} will be set while the player is loading. + * + * @param priorityTaskManager A {@link PriorityTaskManager}, or null to not use one. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setPriorityTaskManager(@Nullable PriorityTaskManager priorityTaskManager) { + Assertions.checkState(!buildCalled); + this.priorityTaskManager = priorityTaskManager; + return this; + } + + /** + * Sets {@link AudioAttributes} that will be used by the player and whether to handle audio + * focus. + * + *

    If audio focus should be handled, the {@link AudioAttributes#usage} must be {@link + * C#USAGE_MEDIA} or {@link C#USAGE_GAME}. Other usages will throw an {@link + * IllegalArgumentException}. + * + * @param audioAttributes {@link AudioAttributes}. + * @param handleAudioFocus Whether the player should hanlde audio focus. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setAudioAttributes(AudioAttributes audioAttributes, boolean handleAudioFocus) { + Assertions.checkState(!buildCalled); + this.audioAttributes = audioAttributes; + this.handleAudioFocus = handleAudioFocus; + return this; + } + + /** + * Sets the {@link C.WakeMode} that will be used by the player. + * + *

    Enabling this feature requires the {@link android.Manifest.permission#WAKE_LOCK} + * permission. It should be used together with a foreground {@link android.app.Service} for use + * cases where playback occurs and the screen is off (e.g. background audio playback). It is not + * useful when the screen will be kept on during playback (e.g. foreground video playback). + * + *

    When enabled, the locks ({@link android.os.PowerManager.WakeLock} / {@link + * android.net.wifi.WifiManager.WifiLock}) will be held whenever the player is in the {@link + * #STATE_READY} or {@link #STATE_BUFFERING} states with {@code playWhenReady = true}. The locks + * held depend on the specified {@link C.WakeMode}. + * + * @param wakeMode A {@link C.WakeMode}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setWakeMode(@C.WakeMode int wakeMode) { + Assertions.checkState(!buildCalled); + this.wakeMode = wakeMode; + return this; + } + + /** + * Sets whether the player should pause automatically when audio is rerouted from a headset to + * device speakers. See the audio + * becoming noisy documentation for more information. + * + * @param handleAudioBecomingNoisy Whether the player should pause automatically when audio is + * rerouted from a headset to device speakers. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setHandleAudioBecomingNoisy(boolean handleAudioBecomingNoisy) { + Assertions.checkState(!buildCalled); + this.handleAudioBecomingNoisy = handleAudioBecomingNoisy; + return this; + } + + /** + * Sets whether silences silences in the audio stream is enabled. + * + * @param skipSilenceEnabled Whether skipping silences is enabled. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setSkipSilenceEnabled(boolean skipSilenceEnabled) { + Assertions.checkState(!buildCalled); + this.skipSilenceEnabled = skipSilenceEnabled; + return this; + } + + /** + * Sets the {@link Renderer.VideoScalingMode} that will be used by the player. + * + *

    Note that the scaling mode only applies if a {@link MediaCodec}-based video {@link + * Renderer} is enabled and if the output surface is owned by a {@link + * android.view.SurfaceView}. + * + * @param videoScalingMode A {@link Renderer.VideoScalingMode}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setVideoScalingMode(@Renderer.VideoScalingMode int videoScalingMode) { + Assertions.checkState(!buildCalled); + this.videoScalingMode = videoScalingMode; + return this; + } + /** * Sets whether media sources should be initialized lazily. * @@ -295,6 +409,37 @@ public class SimpleExoPlayer extends BasePlayer return this; } + /** + * Sets the parameters that control how seek operations are performed. + * + * @param seekParameters The {@link SeekParameters}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setSeekParameters(SeekParameters seekParameters) { + Assertions.checkState(!buildCalled); + this.seekParameters = seekParameters; + return this; + } + + /** + * Sets whether to pause playback at the end of each media item. + * + *

    This means the player will pause at the end of each window in the current {@link + * #getCurrentTimeline() timeline}. Listeners will be informed by a call to {@link + * Player.EventListener#onPlayWhenReadyChanged(boolean, int)} with the reason {@link + * Player#PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM} when this happens. + * + * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) { + Assertions.checkState(!buildCalled); + this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; + return this; + } + /** * Sets whether the player should throw when it detects it's stuck buffering. * @@ -326,7 +471,7 @@ public class SimpleExoPlayer extends BasePlayer /** * Builds a {@link SimpleExoPlayer} instance. * - * @throws IllegalStateException If {@link #build()} has already been called. + * @throws IllegalStateException If this method has already been called. */ public SimpleExoPlayer build() { Assertions.checkState(!buildCalled); @@ -416,6 +561,10 @@ public class SimpleExoPlayer extends BasePlayer protected SimpleExoPlayer(Builder builder) { bandwidthMeter = builder.bandwidthMeter; analyticsCollector = builder.analyticsCollector; + priorityTaskManager = builder.priorityTaskManager; + audioAttributes = builder.audioAttributes; + videoScalingMode = builder.videoScalingMode; + skipSilenceEnabled = builder.skipSilenceEnabled; componentListener = new ComponentListener(); videoListeners = new CopyOnWriteArraySet<>(); audioListeners = new CopyOnWriteArraySet<>(); @@ -436,8 +585,6 @@ public class SimpleExoPlayer extends BasePlayer // Set initial values. audioVolume = 1; audioSessionId = C.AUDIO_SESSION_ID_UNSET; - audioAttributes = AudioAttributes.DEFAULT; - videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; currentCues = Collections.emptyList(); // Build the player and associated objects. @@ -450,6 +597,8 @@ public class SimpleExoPlayer extends BasePlayer bandwidthMeter, analyticsCollector, builder.useLazyPreparation, + builder.seekParameters, + builder.pauseAtEndOfMediaItems, builder.clock, builder.looper); analyticsCollector.setPlayer(player); @@ -461,16 +610,27 @@ public class SimpleExoPlayer extends BasePlayer audioListeners.add(analyticsCollector); addMetadataOutput(analyticsCollector); bandwidthMeter.addEventListener(eventHandler, analyticsCollector); + audioBecomingNoisyManager = new AudioBecomingNoisyManager(builder.context, eventHandler, componentListener); + audioBecomingNoisyManager.setEnabled(builder.handleAudioBecomingNoisy); audioFocusManager = new AudioFocusManager(builder.context, eventHandler, componentListener); + audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null); streamVolumeManager = new StreamVolumeManager(builder.context, eventHandler, componentListener); + streamVolumeManager.setStreamType(Util.getStreamTypeForAudioUsage(audioAttributes.usage)); wakeLockManager = new WakeLockManager(builder.context); + wakeLockManager.setEnabled(builder.wakeMode != C.WAKE_MODE_NONE); wifiLockManager = new WifiLockManager(builder.context); + wifiLockManager.setEnabled(builder.wakeMode == C.WAKE_MODE_NETWORK); deviceInfo = createDeviceInfo(streamVolumeManager); if (builder.throwWhenStuckBuffering) { player.experimental_throwWhenStuckBuffering(); } + + sendRendererMessage(C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_AUDIO_ATTRIBUTES, audioAttributes); + sendRendererMessage(C.TRACK_TYPE_VIDEO, Renderer.MSG_SET_SCALING_MODE, videoScalingMode); + sendRendererMessage( + C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); } @Override @@ -1686,6 +1846,7 @@ public class SimpleExoPlayer extends BasePlayer * @param wakeMode The {@link C.WakeMode} option to keep the device awake during playback. */ public void setWakeMode(@C.WakeMode int wakeMode) { + verifyApplicationThread(); switch (wakeMode) { case C.WAKE_MODE_NONE: wakeLockManager.setEnabled(false); From 6abec07a07f210ea17db1e16ee182a36cbc5edeb Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 14 May 2020 11:51:15 +0100 Subject: [PATCH 0246/1052] Update DefaultDrmSession load error handling Issue:#7309 PiperOrigin-RevId: 311504497 --- .../exoplayer2/drm/DefaultDrmSession.java | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index fa6487587e..8f46d5945c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -29,7 +29,10 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; import com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.CopyOnWriteMultiset; import com.google.android.exoplayer2.util.Log; @@ -53,8 +56,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** Thrown when an unexpected exception or error is thrown during provisioning or key requests. */ public static final class UnexpectedDrmSessionException extends IOException { - public UnexpectedDrmSessionException(Throwable cause) { - super("Unexpected " + cause.getClass().getSimpleName() + ": " + cause.getMessage(), cause); + public UnexpectedDrmSessionException(@Nullable Throwable cause) { + super(cause); } } @@ -552,7 +555,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; void post(int what, Object request, boolean allowRetry) { RequestTask requestTask = - new RequestTask(allowRetry, /* startTimeMs= */ SystemClock.elapsedRealtime(), request); + new RequestTask( + LoadEventInfo.getNewId(), + allowRetry, + /* startTimeMs= */ SystemClock.elapsedRealtime(), + request); obtainMessage(what, requestTask).sendToTarget(); } @@ -572,18 +579,22 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; default: throw new RuntimeException(); } - } catch (Exception e) { + } catch (MediaDrmCallbackException e) { if (maybeRetryRequest(msg, e)) { return; } response = e; + } catch (Exception e) { + Log.w(TAG, "Key/provisioning request produced an unexpected exception. Not retrying.", e); + response = e; } + loadErrorHandlingPolicy.onLoadTaskConcluded(requestTask.taskId); responseHandler .obtainMessage(msg.what, Pair.create(requestTask.request, response)) .sendToTarget(); } - private boolean maybeRetryRequest(Message originalMsg, Exception e) { + private boolean maybeRetryRequest(Message originalMsg, MediaDrmCallbackException exception) { RequestTask requestTask = (RequestTask) originalMsg.obj; if (!requestTask.allowRetry) { return false; @@ -593,14 +604,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; > loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_DRM)) { return false; } - IOException ioException = - e instanceof IOException ? (IOException) e : new UnexpectedDrmSessionException(e); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + requestTask.taskId, + exception.dataSpec, + exception.uriAfterRedirects, + exception.responseHeaders, + SystemClock.elapsedRealtime(), + /* loadDurationMs= */ SystemClock.elapsedRealtime() - requestTask.startTimeMs, + exception.bytesLoaded); + MediaLoadData mediaLoadData = new MediaLoadData(C.DATA_TYPE_DRM); + IOException loadErrorCause = + exception.getCause() instanceof IOException + ? (IOException) exception.getCause() + : new UnexpectedDrmSessionException(exception.getCause()); long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor( - C.DATA_TYPE_DRM, - /* loadDurationMs= */ SystemClock.elapsedRealtime() - requestTask.startTimeMs, - ioException, - requestTask.errorCount); + new LoadErrorInfo( + loadEventInfo, mediaLoadData, loadErrorCause, requestTask.errorCount)); if (retryDelayMs == C.TIME_UNSET) { // The error is fatal. return false; @@ -612,12 +633,14 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private static final class RequestTask { + public final long taskId; public final boolean allowRetry; public final long startTimeMs; public final Object request; public int errorCount; - public RequestTask(boolean allowRetry, long startTimeMs, Object request) { + public RequestTask(long taskId, boolean allowRetry, long startTimeMs, Object request) { + this.taskId = taskId; this.allowRetry = allowRetry; this.startTimeMs = startTimeMs; this.request = request; From c1aa9b917bae27a7a1d59e74de2ef79daa644da3 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 14 May 2020 13:22:24 +0100 Subject: [PATCH 0247/1052] Make use of MediaItem in ProgressiveDownloader PiperOrigin-RevId: 311513746 --- .../offline/ProgressiveDownloader.java | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 6ad186b575..7ad3932ef8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -18,9 +18,11 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; import java.io.IOException; import java.util.concurrent.Executor; @@ -37,22 +39,46 @@ public final class ProgressiveDownloader implements Downloader { @Nullable private volatile Thread downloadThread; - /** - * @param uri Uri of the data to be downloaded. - * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache - * indexing. May be null. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. - */ + /** @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public ProgressiveDownloader( Uri uri, @Nullable String customCacheKey, CacheDataSource.Factory cacheDataSourceFactory) { this(uri, customCacheKey, cacheDataSourceFactory, Runnable::run); } /** - * @param uri Uri of the data to be downloaded. - * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache - * indexing. May be null. + * Creates a new instance. + * + * @param mediaItem The media item with a uri to the stream to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public ProgressiveDownloader( + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory, Executor)} + * instead. + */ + @Deprecated + public ProgressiveDownloader( + Uri uri, + @Nullable String customCacheKey, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(uri).setCustomCacheKey(customCacheKey).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The media item with a uri to the stream to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. In @@ -60,14 +86,12 @@ public final class ProgressiveDownloader implements Downloader { * download by allowing parts of it to be executed in parallel. */ public ProgressiveDownloader( - Uri uri, - @Nullable String customCacheKey, - CacheDataSource.Factory cacheDataSourceFactory, - Executor executor) { + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + Assertions.checkNotNull(mediaItem.playbackProperties); dataSpec = new DataSpec.Builder() - .setUri(uri) - .setKey(customCacheKey) + .setUri(mediaItem.playbackProperties.uri) + .setKey(mediaItem.playbackProperties.customCacheKey) .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) .build(); dataSource = cacheDataSourceFactory.createDataSourceForDownloading(); @@ -115,10 +139,10 @@ public final class ProgressiveDownloader implements Downloader { private static final class ProgressForwarder implements CacheUtil.ProgressListener { - private final ProgressListener progessListener; + private final ProgressListener progressListener; public ProgressForwarder(ProgressListener progressListener) { - this.progessListener = progressListener; + this.progressListener = progressListener; } @Override @@ -127,7 +151,7 @@ public final class ProgressiveDownloader implements Downloader { contentLength == C.LENGTH_UNSET || contentLength == 0 ? C.PERCENTAGE_UNSET : ((bytesCached * 100f) / contentLength); - progessListener.onProgress(contentLength, bytesCached, percentDownloaded); + progressListener.onProgress(contentLength, bytesCached, percentDownloaded); } } } From 9e35c6c28c85d375517d78416fedf7d12c1b4a7e Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 14 May 2020 15:24:40 +0100 Subject: [PATCH 0248/1052] Make all segment downloader use the media item PiperOrigin-RevId: 311527440 --- .../exoplayer2/offline/SegmentDownloader.java | 15 ++--- .../source/dash/offline/DashDownloader.java | 56 ++++++++++++----- .../dash/offline/DashDownloaderTest.java | 5 +- .../source/hls/offline/HlsDownloader.java | 59 ++++++++++++------ .../source/hls/offline/HlsDownloaderTest.java | 5 +- .../smoothstreaming/offline/SsDownloader.java | 60 ++++++++++++++----- .../playbacktests/gts/DashDownloadTest.java | 5 +- 7 files changed, 146 insertions(+), 59 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 601945c69d..544eda85d7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -15,10 +15,13 @@ */ package com.google.android.exoplayer2.offline; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.ParsingLoadable; @@ -79,10 +82,8 @@ public abstract class SegmentDownloader> impleme @Nullable private volatile Thread downloadThread; /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. + * @param mediaItem The {@link MediaItem} to be downloaded. * @param manifestParser A parser for the manifest. - * @param streamKeys Keys defining which streams in the manifest should be selected for download. - * If empty, all streams are downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -90,14 +91,14 @@ public abstract class SegmentDownloader> impleme * allowing parts of it to be executed in parallel. */ public SegmentDownloader( - Uri manifestUri, + MediaItem mediaItem, Parser manifestParser, - List streamKeys, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - this.manifestDataSpec = getCompressibleDataSpec(manifestUri); + checkNotNull(mediaItem.playbackProperties); + this.manifestDataSpec = getCompressibleDataSpec(mediaItem.playbackProperties.uri); this.manifestParser = manifestParser; - this.streamKeys = new ArrayList<>(streamKeys); + this.streamKeys = new ArrayList<>(mediaItem.playbackProperties.streamKeys); this.cacheDataSourceFactory = cacheDataSourceFactory; this.executor = executor; isCanceled = new AtomicBoolean(); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java index 7b85d46f66..a367c73747 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source.dash.offline; import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.offline.DownloadException; import com.google.android.exoplayer2.offline.SegmentDownloader; @@ -54,7 +55,11 @@ import java.util.concurrent.Executor; * // period. * DashDownloader dashDownloader = * new DashDownloader( - * manifestUrl, Collections.singletonList(new StreamKey(0, 0, 0)), cacheDataSourceFactory); + * new MediaItem.Builder() + * .setUri(manifestUrl) + * .setStreamKeys(Collections.singletonList(new StreamKey(0, 0, 0))) + * .build(), + * cacheDataSourceFactory); * // Perform the download. * dashDownloader.download(progressListener); * // Use the downloaded data for playback. @@ -64,22 +69,44 @@ import java.util.concurrent.Executor; */ public final class DashDownloader extends SegmentDownloader { - /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which representations in the manifest should be selected for - * download. If empty, all representations are downloaded. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. - */ + /** @deprecated Use {@link #DashDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public DashDownloader( Uri manifestUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run); } /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which representations in the manifest should be selected for - * download. If empty, all representations are downloaded. + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public DashDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #DashDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. + */ + @Deprecated + public DashDownloader( + Uri manifestUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(manifestUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -87,11 +114,8 @@ public final class DashDownloader extends SegmentDownloader { * allowing parts of it to be executed in parallel. */ public DashDownloader( - Uri manifestUri, - List streamKeys, - CacheDataSource.Factory cacheDataSourceFactory, - Executor executor) { - super(manifestUri, new DashManifestParser(), streamKeys, cacheDataSourceFactory, executor); + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + super(mediaItem, new DashManifestParser(), cacheDataSourceFactory, executor); } @Override diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java index 49e111b7a7..7786a00758 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.when; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadException; import com.google.android.exoplayer2.offline.DownloadRequest; @@ -337,7 +338,9 @@ public class DashDownloaderTest { new CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(upstreamDataSourceFactory); - return new DashDownloader(TEST_MPD_URI, keysList(keys), cacheDataSourceFactory); + return new DashDownloader( + new MediaItem.Builder().setUri(TEST_MPD_URI).setStreamKeys(keysList(keys)).build(), + cacheDataSourceFactory); } private static ArrayList keysList(StreamKey... keys) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java index 2e97c4bc58..2b604ee8be 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.hls.offline; import android.net.Uri; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.SegmentDownloader; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; @@ -47,8 +48,13 @@ import java.util.concurrent.Executor; * // Create a downloader for the first variant in a master playlist. * HlsDownloader hlsDownloader = * new HlsDownloader( - * playlistUri, - * Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)); + * new MediaItem.Builder() + * .setUri(playlistUri) + * .setStreamKeys( + * Collections.singletonList( + * new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0))) + * .build(), + * Collections.singletonList(); * // Perform the download. * hlsDownloader.download(progressListener); * // Use the downloaded data for playback. @@ -58,22 +64,44 @@ import java.util.concurrent.Executor; */ public final class HlsDownloader extends SegmentDownloader { - /** - * @param playlistUri The {@link Uri} of the playlist to be downloaded. - * @param streamKeys Keys defining which renditions in the playlist should be selected for - * download. If empty, all renditions are downloaded. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. - */ + /** @deprecated Use {@link #HlsDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public HlsDownloader( Uri playlistUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { this(playlistUri, streamKeys, cacheDataSourceFactory, Runnable::run); } /** - * @param playlistUri The {@link Uri} of the playlist to be downloaded. - * @param streamKeys Keys defining which renditions in the playlist should be selected for - * download. If empty, all renditions are downloaded. + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public HlsDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #HlsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. + */ + @Deprecated + public HlsDownloader( + Uri playlistUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(playlistUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -81,11 +109,8 @@ public final class HlsDownloader extends SegmentDownloader { * allowing parts of it to be executed in parallel. */ public HlsDownloader( - Uri playlistUri, - List streamKeys, - CacheDataSource.Factory cacheDataSourceFactory, - Executor executor) { - super(playlistUri, new HlsPlaylistParser(), streamKeys, cacheDataSourceFactory, executor); + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + super(mediaItem, new HlsPlaylistParser(), cacheDataSourceFactory, executor); } @Override diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java index 0dcae17f74..225d57ea5a 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java @@ -37,6 +37,7 @@ import static com.google.common.truth.Truth.assertThat; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.offline.Downloader; @@ -219,7 +220,9 @@ public class HlsDownloaderTest { new CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(new FakeDataSource.Factory().setFakeDataSet(fakeDataSet)); - return new HlsDownloader(Uri.parse(mediaPlaylistUri), keys, cacheDataSourceFactory); + return new HlsDownloader( + new MediaItem.Builder().setUri(mediaPlaylistUri).setStreamKeys(keys).build(), + cacheDataSourceFactory); } private static ArrayList getKeys(int... variantIndices) { diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java index 3a2cf10439..bb1bb06e6c 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java @@ -15,7 +15,10 @@ */ package com.google.android.exoplayer2.source.smoothstreaming.offline; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.SegmentDownloader; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; @@ -43,8 +46,10 @@ import java.util.concurrent.Executor; * // Create a downloader for the first track of the first stream element. * SsDownloader ssDownloader = * new SsDownloader( - * manifestUrl, - * Collections.singletonList(new StreamKey(0, 0)), + * new MediaItem.Builder() + * .setUri(manifestUri) + * .setStreamKeys(Collections.singletonList(new StreamKey(0, 0))) + * .build(), * cacheDataSourceFactory); * // Perform the download. * ssDownloader.download(progressListener); @@ -56,21 +61,45 @@ import java.util.concurrent.Executor; public final class SsDownloader extends SegmentDownloader { /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which streams in the manifest should be selected for download. - * If empty, all streams are downloaded. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. + * @deprecated Use {@link #SsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public SsDownloader( Uri manifestUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run); } /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which streams in the manifest should be selected for download. - * If empty, all streams are downloaded. + * Creates an instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public SsDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #SsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. + */ + @Deprecated + public SsDownloader( + Uri manifestUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(manifestUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates an instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -78,14 +107,13 @@ public final class SsDownloader extends SegmentDownloader { * allowing parts of it to be executed in parallel. */ public SsDownloader( - Uri manifestUri, - List streamKeys, - CacheDataSource.Factory cacheDataSourceFactory, - Executor executor) { + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { super( - SsUtil.fixManifestUri(manifestUri), + mediaItem + .buildUpon() + .setUri(SsUtil.fixManifestUri(checkNotNull(mediaItem.playbackProperties).uri)) + .build(), new SsManifestParser(), - streamKeys, cacheDataSourceFactory, executor); } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java index 5e92dfda57..6e9e55e1c1 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java @@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertWithMessage; import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.rule.ActivityTestRule; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.dash.DashUtil; import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; @@ -120,7 +121,9 @@ public final class DashDownloadTest { new CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(httpDataSourceFactory); - return new DashDownloader(MANIFEST_URI, keys, cacheDataSourceFactory); + return new DashDownloader( + new MediaItem.Builder().setUri(MANIFEST_URI).setStreamKeys(keys).build(), + cacheDataSourceFactory); } } From 758e99e3f1dc79cab55c390ed584d1a76eb3e2a6 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 14 May 2020 15:55:36 +0100 Subject: [PATCH 0249/1052] Remove last references to old load error handling methods PiperOrigin-RevId: 311531734 --- .../DefaultLoadErrorHandlingPolicyTest.java | 31 ++++++++++++++++--- .../playlist/DefaultHlsPlaylistTracker.java | 28 ++++++++++------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java index 8840abfcdc..28ae4f748f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java @@ -21,7 +21,10 @@ import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import java.io.IOException; import java.util.Collections; import org.junit.Test; @@ -31,6 +34,18 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class DefaultLoadErrorHandlingPolicyTest { + private static final LoadEventInfo PLACEHOLDER_LOAD_EVENT_INFO = + new LoadEventInfo( + LoadEventInfo.getNewId(), + new DataSpec(Uri.EMPTY), + Uri.EMPTY, + /* responseHeaders= */ Collections.emptyMap(), + /* elapsedRealtimeMs= */ 5000, + /* loadDurationMs= */ 1000, + /* bytesLoaded= */ 0); + private static final MediaLoadData PLACEHOLDER_MEDIA_LOAD_DATA = + new MediaLoadData(/* dataType= */ C.DATA_TYPE_UNKNOWN); + @Test public void getBlacklistDurationMsFor_blacklist404() { InvalidResponseCodeException exception = @@ -77,13 +92,19 @@ public final class DefaultLoadErrorHandlingPolicyTest { } private static long getDefaultPolicyBlacklistOutputFor(IOException exception) { - return new DefaultLoadErrorHandlingPolicy() - .getBlacklistDurationMsFor( - C.DATA_TYPE_MEDIA, /* loadDurationMs= */ 1000, exception, /* errorCount= */ 1); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo( + PLACEHOLDER_LOAD_EVENT_INFO, + PLACEHOLDER_MEDIA_LOAD_DATA, + exception, + /* errorCount= */ 1); + return new DefaultLoadErrorHandlingPolicy().getBlacklistDurationMsFor(loadErrorInfo); } private static long getDefaultPolicyRetryDelayOutputFor(IOException exception, int errorCount) { - return new DefaultLoadErrorHandlingPolicy() - .getRetryDelayMsFor(C.DATA_TYPE_MEDIA, /* loadDurationMs= */ 1000, exception, errorCount); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo( + PLACEHOLDER_LOAD_EVENT_INFO, PLACEHOLDER_MEDIA_LOAD_DATA, exception, errorCount); + return new DefaultLoadErrorHandlingPolicy().getRetryDelayMsFor(loadErrorInfo); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index f179447785..d43284a211 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -238,12 +238,6 @@ public final class DefaultHlsPlaylistTracker primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; createBundles(masterPlaylist.mediaPlaylistUrls); MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); - if (isMediaPlaylist) { - // We don't need to load the playlist again. We can use the same result. - primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); - } else { - primaryBundle.loadPlaylist(); - } LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -253,6 +247,12 @@ public final class DefaultHlsPlaylistTracker elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); + } else { + primaryBundle.loadPlaylist(); + } loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); } @@ -540,15 +540,15 @@ public final class DefaultHlsPlaylistTracker elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); - loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); if (result instanceof HlsMediaPlaylist) { - processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); } else { playlistError = new ParserException("Loaded playlist has unexpected type."); eventDispatcher.loadError( loadEventInfo, C.DATA_TYPE_MANIFEST, playlistError, /* wasCanceled= */ true); } + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); } @Override @@ -639,7 +639,8 @@ public final class DefaultHlsPlaylistTracker mediaPlaylistLoadable.type); } - private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) { + private void processLoadedPlaylist( + HlsMediaPlaylist loadedPlaylist, LoadEventInfo loadEventInfo) { HlsMediaPlaylist oldPlaylist = playlistSnapshot; long currentTimeMs = SystemClock.elapsedRealtime(); lastSnapshotLoadMs = currentTimeMs; @@ -661,9 +662,14 @@ public final class DefaultHlsPlaylistTracker * playlistStuckTargetDurationCoefficient) { // TODO: Allow customization of stuck playlists handling. playlistError = new PlaylistStuckException(playlistUrl); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo( + loadEventInfo, + new MediaLoadData(C.DATA_TYPE_MANIFEST), + playlistError, + /* errorCount= */ 1); long blacklistDurationMs = - loadErrorHandlingPolicy.getBlacklistDurationMsFor( - C.DATA_TYPE_MANIFEST, loadDurationMs, playlistError, /* errorCount= */ 1); + loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); notifyPlaylistError(playlistUrl, blacklistDurationMs); if (blacklistDurationMs != C.TIME_UNSET) { blacklistPlaylist(blacklistDurationMs); From a39233d2fd0b26f2345f7ed60e378dafd09d2375 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 14 May 2020 17:44:08 +0100 Subject: [PATCH 0250/1052] Limit CEA-608 captions to 32 chars per line ANSI/CTA-608-E R-2014 spec defines exactly 32 columns on the screen, and limits all lines to this length. See 3.2.2 definition of 'Column'. issue:#7341 PiperOrigin-RevId: 311549881 --- RELEASENOTES.md | 11 +++--- .../google/android/exoplayer2/util/Util.java | 30 ++++++++++++++ .../android/exoplayer2/util/UtilTest.java | 39 +++++++++++++++++++ .../exoplayer2/text/cea/Cea608Decoder.java | 17 +++++--- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1defae4356..24262cb949 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -123,6 +123,8 @@ * Stop parsing unsupported WebVTT CSS properties. The spec provides an [exhaustive list](https://www.w3.org/TR/webvtt1/#the-cue-pseudo-element) of which are supported. + * Ignore excess characters in CEA-608 lines (max length is 32) + ([#7341](https://github.com/google/ExoPlayer/issues/7341)). * DRM: * Add support for attaching DRM sessions to clear content in the demo app. * Remove `DrmSessionManager` references from all renderers. @@ -188,11 +190,10 @@ * IMA extension: Upgrade to IMA SDK version 3.18.2, and migrate to new preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). * IMA extension: - * Upgrade to IMA SDK version 3.19.0, and migrate to new - preloading APIs - ([#6429](https://github.com/google/ExoPlayer/issues/6429)). - * Add support for timing out ad preloading, to avoid playback getting - stuck if an ad group unexpectedly fails to load. + * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). + * Add support for timing out ad preloading, to avoid playback getting + stuck if an ad group unexpectedly fails to load. * OkHttp extension: Upgrade OkHttp dependency to 3.12.11. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index b1c554cf88..e7b0c5cb7d 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -1291,6 +1291,36 @@ public final class Util { return (toUnsignedLong(mostSignificantBits) << 32) | toUnsignedLong(leastSignificantBits); } + /** + * Truncates a sequence of ASCII characters to a maximum length. + * + *

    Note: This is not safe to use in general on Unicode text because it may separate + * characters from combining characters or split up surrogate pairs. + * + * @param sequence The character sequence to truncate. + * @param maxLength The max length to truncate to. + * @return {@code sequence} directly if {@code sequence.length() <= maxLength}, otherwise {@code + * sequence.subsequence(0, maxLength}. + */ + public static CharSequence truncateAscii(CharSequence sequence, int maxLength) { + return sequence.length() <= maxLength ? sequence : sequence.subSequence(0, maxLength); + } + + /** + * Truncates a string of ASCII characters to a maximum length. + * + *

    Note: This is not safe to use in general on Unicode text because it may separate + * characters from combining characters or split up surrogate pairs. + * + * @param string The string to truncate. + * @param maxLength The max length to truncate to. + * @return {@code string} directly if {@code string.length() <= maxLength}, otherwise {@code + * string.substring(0, maxLength}. + */ + public static String truncateAscii(String string, int maxLength) { + return string.length() <= maxLength ? string : string.substring(0, maxLength); + } + /** * Returns a byte array containing values parsed from the hex string provided. * diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java index 2e523a32c6..861267fc3a 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java @@ -24,9 +24,14 @@ import static com.google.android.exoplayer2.util.Util.parseXsDuration; import static com.google.android.exoplayer2.util.Util.unescapeFileName; import static com.google.common.truth.Truth.assertThat; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.StrikethroughSpan; +import android.text.style.UnderlineSpan; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.testutil.truth.SpannedSubject; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; @@ -713,6 +718,40 @@ public class UtilTest { assertThat(Util.toLong(0xFEDCBA, 0x87654321)).isEqualTo(0xFEDCBA_87654321L); } + @Test + public void truncateAscii_shortInput_returnsInput() { + String input = "a short string"; + + assertThat(Util.truncateAscii(input, 100)).isSameInstanceAs(input); + assertThat(Util.truncateAscii((CharSequence) input, 100)).isSameInstanceAs(input); + } + + @Test + public void truncateAscii_longInput_truncated() { + String input = "a much longer string"; + + assertThat(Util.truncateAscii(input, 5)).isEqualTo("a muc"); + assertThat(Util.truncateAscii((CharSequence) input, 5).toString()).isEqualTo("a muc"); + } + + @Test + public void truncateAscii_preservesStylingSpans() { + SpannableString input = new SpannableString("a short string"); + input.setSpan(new UnderlineSpan(), 0, 10, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + input.setSpan(new StrikethroughSpan(), 4, 10, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + CharSequence result = Util.truncateAscii(input, 7); + + assertThat(result).isInstanceOf(SpannableString.class); + assertThat(result.toString()).isEqualTo("a short"); + SpannedSubject.assertThat((Spanned) result) + .hasUnderlineSpanBetween(0, 7) + .withFlags(Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + SpannedSubject.assertThat((Spanned) result) + .hasStrikethroughSpanBetween(4, 7) + .withFlags(Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + @Test public void toHexString_returnsHexString() { byte[] bytes = TestUtil.createByteArray(0x12, 0xFC, 0x06); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 75e86c4113..dcb3d96841 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -37,6 +37,7 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; @@ -868,7 +869,11 @@ public final class Cea608Decoder extends CeaDecoder { } public void append(char text) { - captionStringBuilder.append(text); + // Don't accept more than 32 chars. We'll trim further, considering indent & tabOffset, in + // build(). + if (captionStringBuilder.length() < SCREEN_CHARWIDTH) { + captionStringBuilder.append(text); + } } public void rollUp() { @@ -883,14 +888,17 @@ public final class Cea608Decoder extends CeaDecoder { @Nullable public Cue build(@Cue.AnchorType int forcedPositionAnchor) { + // The number of empty columns before the start of the text, in the range [0-31]. + int startPadding = indent + tabOffset; + int maxTextLength = SCREEN_CHARWIDTH - startPadding; SpannableStringBuilder cueString = new SpannableStringBuilder(); // Add any rolled up captions, separated by new lines. for (int i = 0; i < rolledUpCaptions.size(); i++) { - cueString.append(rolledUpCaptions.get(i)); + cueString.append(Util.truncateAscii(rolledUpCaptions.get(i), maxTextLength)); cueString.append('\n'); } // Add the current line. - cueString.append(buildCurrentLine()); + cueString.append(Util.truncateAscii(buildCurrentLine(), maxTextLength)); if (cueString.length() == 0) { // The cue is empty. @@ -898,8 +906,7 @@ public final class Cea608Decoder extends CeaDecoder { } int positionAnchor; - // The number of empty columns before the start of the text, in the range [0-31]. - int startPadding = indent + tabOffset; + // The number of empty columns after the end of the text, in the same range. int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); int startEndPaddingDelta = startPadding - endPadding; From 710374f83aaa1b047a99901d39e15bb769b743c0 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 14 May 2020 17:56:00 +0100 Subject: [PATCH 0251/1052] Override WebViewSubtitleOutput.onLayout() Some of the CSS font sizes are derived from the current view height, if this calculation is done before the view has been measured then a zero view height results in a zero px font size and no visible text. This can happen when the view type is changed (and so the WebViewSubtitleOutput has been recently added to the SubtitleView ViewGroup). PiperOrigin-RevId: 311552052 --- .../android/exoplayer2/ui/SubtitleView.java | 1 - .../exoplayer2/ui/WebViewSubtitleOutput.java | 51 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index 68c48d1b34..e066fa0f8a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -201,7 +201,6 @@ public final class SubtitleView extends FrameLayout implements TextOutput { innerSubtitleView = view; output = view; addView(view); - updateOutput(); } /** diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index 1df9d754ac..2bf3d2e6ab 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -16,6 +16,9 @@ */ package com.google.android.exoplayer2.ui; +import static com.google.android.exoplayer2.ui.SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION; +import static com.google.android.exoplayer2.ui.SubtitleView.DEFAULT_TEXT_SIZE_FRACTION; + import android.content.Context; import android.graphics.Color; import android.text.Layout; @@ -32,6 +35,7 @@ import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.util.Util; import java.nio.charset.Charset; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -52,6 +56,12 @@ import java.util.List; private final WebView webView; + private List textCues; + private CaptionStyleCompat style; + private float defaultTextSize; + @Cue.TextSizeType private int defaultTextSizeType; + private float bottomPaddingFraction; + public WebViewSubtitleOutput(Context context) { this(context, null); } @@ -59,6 +69,12 @@ import java.util.List; public WebViewSubtitleOutput(Context context, @Nullable AttributeSet attrs) { super(context, attrs); + textCues = Collections.emptyList(); + style = CaptionStyleCompat.DEFAULT; + defaultTextSize = DEFAULT_TEXT_SIZE_FRACTION; + defaultTextSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL; + bottomPaddingFraction = DEFAULT_BOTTOM_PADDING_FRACTION; + canvasSubtitleOutput = new CanvasSubtitleOutput(context, attrs); webView = new WebView(context, attrs) { @@ -89,6 +105,11 @@ import java.util.List; float textSize, @Cue.TextSizeType int textSizeType, float bottomPaddingFraction) { + this.style = style; + this.defaultTextSize = textSize; + this.defaultTextSizeType = textSizeType; + this.bottomPaddingFraction = bottomPaddingFraction; + List bitmapCues = new ArrayList<>(); List textCues = new ArrayList<>(); for (int i = 0; i < cues.size(); i++) { @@ -99,10 +120,27 @@ import java.util.List; textCues.add(cue); } } + + if (!this.textCues.isEmpty() || !textCues.isEmpty()) { + this.textCues = textCues; + // Skip updating if this is a transition from empty-cues to empty-cues (i.e. only positioning + // info has changed) since a positional-only change with no cues is a visual no-op. The new + // position info will be used when we get non-empty cue data in a future update() call. + updateWebView(); + } canvasSubtitleOutput.update(bitmapCues, style, textSize, textSizeType, bottomPaddingFraction); // Invalidate to trigger canvasSubtitleOutput to draw. invalidate(); - updateWebView(textCues, style, textSize, textSizeType, bottomPaddingFraction); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed && !textCues.isEmpty()) { + // A positional change with no cues is a visual no-op. The new layout info will be used + // automatically next time update() is called. + updateWebView(); + } } /** @@ -115,12 +153,7 @@ import java.util.List; webView.destroy(); } - private void updateWebView( - List cues, - CaptionStyleCompat style, - float defaultTextSize, - @Cue.TextSizeType int defaultTextSizeType, - float bottomPaddingFraction) { + private void updateWebView() { StringBuilder html = new StringBuilder(); html.append( Util.formatInvariant( @@ -141,8 +174,8 @@ import java.util.List; String backgroundColorCss = HtmlUtils.toCssRgba(style.backgroundColor); - for (int i = 0; i < cues.size(); i++) { - Cue cue = cues.get(i); + for (int i = 0; i < textCues.size(); i++) { + Cue cue = textCues.get(i); float positionPercent = (cue.position != Cue.DIMEN_UNSET) ? (cue.position * 100) : 50; int positionAnchorTranslatePercent = anchorTypeToTranslatePercent(cue.positionAnchor); From f91cfb12e89c595fa4c5ff4e168d304ed73bb990 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 15 May 2020 00:27:33 +0100 Subject: [PATCH 0252/1052] Attach ExoMediaCryptoType for progressive streams PiperOrigin-RevId: 311628160 --- .../android/exoplayer2/source/ProgressiveMediaPeriod.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 32c96e14f7..2630132044 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -781,6 +781,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; trackFormat = trackFormat.buildUpon().setAverageBitrate(icyHeaders.bitrate).build(); } } + if (trackFormat.drmInitData != null) { + trackFormat = + trackFormat.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(trackFormat.drmInitData)); + } trackArray[i] = new TrackGroup(trackFormat); } trackState = new TrackState(new TrackGroupArray(trackArray), trackIsAudioVideoFlags); From b9abe2d0d6e68768a70af2a9cc1fffe5b19e7dab Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 15 May 2020 10:48:11 +0100 Subject: [PATCH 0253/1052] Clean up samples list - Add Widevine AV1 streams - Remove SD and HD only Widevine streams (we don't need so many!) - Simplify naming PiperOrigin-RevId: 311697741 --- demos/main/src/main/assets/media.exolist.json | 286 ++++++------------ 1 file changed, 97 insertions(+), 189 deletions(-) diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index b9f3d63694..b13241f691 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -3,214 +3,153 @@ "name": "YouTube DASH", "samples": [ { - "name": "Google Glass (MP4,H264)", + "name": "Google Glass H264 (MP4)", "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0", "extension": "mpd" }, { - "name": "Google Play (MP4,H264)", + "name": "Google Play H264 (MP4)", "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=A2716F75795F5D2AF0E88962FFCD10DB79384F29.84308FF04844498CE6FBCE4731507882B8307798&key=ik0", "extension": "mpd" }, { - "name": "Google Glass (WebM,VP9)", + "name": "Google Glass VP9 (WebM)", "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=249B04F79E984D7F86B4D8DB48AE6FAF41C17AB3.7B9F0EC0505E1566E59B8E488E9419F253DDF413&key=ik0", "extension": "mpd" }, { - "name": "Google Play (WebM,VP9)", + "name": "Google Play VP9 (WebM)", "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=B1C2A74783AC1CC4865EB312D7DD2D48230CC9FD.BD153B9882175F1F94BFE5141A5482313EA38E8D&key=ik0", "extension": "mpd" } ] }, { - "name": "Widevine DASH Policy Tests (GTS)", + "name": "Widevine GTS policy tests", "samples": [ { - "name": "WV: HDCP not specified", + "name": "SW secure crypto (L3)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=d286538032258a1c&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: HDCP not required", + "name": "SW secure decode", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=48fcc369939ac96c&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_DECODE&provider=widevine_test" }, { - "name": "WV: HDCP required", + "name": "HW secure crypto", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=e06c39f1151da3df&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: Secure video path required (MP4,H264)", + "name": "HW secure decode", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_DECODE&provider=widevine_test" }, { - "name": "WV: Secure video path required (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" - }, - { - "name": "WV: Secure video path required (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" - }, - { - "name": "WV: HDCP + secure video path required", + "name": "HW secure all (L1)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=efd045b1eb61888a&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" }, { - "name": "WV: 30s license duration (fails at ~30s)", + "name": "30s license (fails at ~30s)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=f9a34cab7b05881a&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_CAN_RENEW_FALSE_LICENSE_30S_PLAYBACK_30S&provider=widevine_test" + }, + { + "name": "HDCP not required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NONE&provider=widevine_test" + }, + { + "name": "HDCP 1.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V1&provider=widevine_test" + }, + { + "name": "HDCP 2.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2&provider=widevine_test" + }, + { + "name": "HDCP 2.1 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_1&provider=widevine_test" + }, + { + "name": "HDCP 2.2 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_2&provider=widevine_test" + }, + { + "name": "HDCP no digital output", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NO_DIGITAL_OUTPUT&provider=widevine_test" } ] }, { - "name": "Widevine HDCP Capabilities Tests", + "name": "Widevine DASH H264 (MP4)", "samples": [ { - "name": "WV: HDCP: None (not required)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_None&provider=widevine_test" - }, - { - "name": "WV: HDCP: 1.0 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V1&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.0 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.1 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2_1&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.2 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2_2&provider=widevine_test" - }, - { - "name": "WV: HDCP: No digital output", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_NO_DIGTAL_OUTPUT&provider=widevine_test" - } - ] - }, - { - "name": "Widevine DASH: MP4,H264", - "samples": [ - { - "name": "WV: Clear SD & HD (MP4,H264)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd" }, { - "name": "WV: Clear SD (MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (MP4,H264)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_uhd.mpd" }, { - "name": "WV: Secure SD & HD (cenc,MP4,H264)", + "name": "Secure (cenc)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cenc,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cenc,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cenc,MP4,H264)", + "name": "Secure UHD (cenc)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD & HD (cbc1,MP4,H264)", + "name": "Secure (cbc1)", "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cbc1,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cbc1,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cbc1,MP4,H264)", + "name": "Secure UHD (cbc1)", "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD & HD (cbcs,MP4,H264)", + "name": "Secure (cbcs)", "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cbcs,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cbcs,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cbcs,MP4,H264)", + "name": "Secure UHD (cbcs)", "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure and Clear SD & HD (cenc,MP4,H264)", + "name": "Secure -> Clear -> Secure (cenc)", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/widevine/tears_enc_clear_enc.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test", @@ -219,68 +158,36 @@ ] }, { - "name": "Widevine DASH: WebM,VP9", + "name": "Widevine DASH VP9 (WebM)", "samples": [ { - "name": "WV: Clear SD & HD (WebM,VP9)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears.mpd" }, { - "name": "WV: Clear SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (WebM,VP9)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_uhd.mpd" }, { - "name": "WV: Secure Fullsample SD & HD (WebM,VP9)", + "name": "Secure (full-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Fullsample SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Fullsample HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Fullsample UHD (WebM,VP9)", + "name": "Secure UHD (full-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Subsample SD & HD (WebM,VP9)", + "name": "Secure (sub-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Subsample SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample UHD (WebM,VP9)", + "name": "Secure UHD (sub-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" @@ -288,50 +195,51 @@ ] }, { - "name": "Widevine DASH: MP4,H265", + "name": "Widevine DASH H265 (MP4)", "samples": [ { - "name": "WV: Clear SD & HD (MP4,H265)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears.mpd" }, { - "name": "WV: Clear SD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (MP4,H265)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_uhd.mpd" }, { - "name": "WV: Secure SD & HD (MP4,H265)", + "name": "Secure", "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (MP4,H265)", + "name": "Secure UHD", "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" } ] }, + { + "name": "Widevine AV1 (WebM)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/2019/clear/av1/24/webm/llama_av1_480p_400.webm" + }, + { + "name": "Secure L3", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" + }, + { + "name": "Secure L1", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" + } + ] + }, { "name": "SmoothStreaming", "samples": [ @@ -362,7 +270,7 @@ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8" }, { - "name": "Apple master playlist advanced (fMP4)", + "name": "Apple master playlist advanced (FMP4)", "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8" }, { From e4cb74057ae024af329e3dae6f25857746d6d21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Varga?= Date: Mon, 18 May 2020 13:41:07 +0200 Subject: [PATCH 0254/1052] add ability to interrupt HLS chunk download --- .../source/hls/HlsSampleStreamWrapper.java | 105 +++++++----------- 1 file changed, 42 insertions(+), 63 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index a9baf53add..2eced4544b 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -68,7 +68,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.ListIterator; import java.util.Map; import java.util.Set; import org.checkerframework.checker.nullness.compatqual.NullableType; @@ -147,6 +146,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private @MonotonicNonNull Format upstreamTrackFormat; @Nullable private Format downstreamTrackFormat; private boolean released; + private boolean interrupted; + private int preferredQueueSize; // Tracks are complicated in HLS. See documentation of buildTracksFromSampleStreams for details. // Indexed by track (as exposed by this source). @@ -697,12 +698,25 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void reevaluateBuffer(long positionUs) { - if (loader.isLoading() || isPendingReset()) { + if (loader.hasFatalError() || isPendingReset()) { return; } int currentQueueSize = mediaChunks.size(); - int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (loader.isLoading()) { + if (currentQueueSize > preferredQueueSize) { + interrupted = true; + loader.cancelLoading(); + } + return; + } + + upstreamDiscard(); + } + + private void upstreamDiscard() { + int currentQueueSize = mediaChunks.size(); if (currentQueueSize <= preferredQueueSize) { return; } @@ -722,8 +736,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; + " chunk count: " + currentQueueSize + " target chunk count: " + newQueueSize); - dumpCurrentChunkList(); - long endTimeUs = getLastMediaChunk().endTimeUs; HlsMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); if (mediaChunks.isEmpty()) { @@ -731,8 +743,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } loadingFinished = false; - dumpCurrentChunkList(); - eventDispatcher.upstreamDiscarded(primarySampleQueueType, firstRemovedChunk.startTimeUs, endTimeUs); + eventDispatcher + .upstreamDiscarded(primarySampleQueueType, firstRemovedChunk.startTimeUs, endTimeUs); } /** @@ -746,40 +758,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; HlsMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); + int[] firstSamples = firstRemovedChunk.getFirstSampleIndexes(); + if (firstSamples.length != 0) { + for (int i = 0; i < sampleQueues.length; i++) { + Log.d(TAG, "discardUpstreamSamples() - stream: from index: " + firstSamples[i]); - int [] firstSamples = firstRemovedChunk.getFirstSampleIndexes(); - for (int i=0; i < sampleQueues.length; i++) { - Log.d(TAG, "discardUpstreamSamples() - stream: " + " from index: " + firstSamples[i]); - - sampleQueues[i].discardUpstreamSamples(firstSamples[i]); + sampleQueues[i].discardUpstreamSamples(firstSamples[i]); + } } - dumpCurrentChunkList(); - return firstRemovedChunk; } - - public void dumpCurrentChunkList() { - Log.d(TAG, "Dump MediaChunks - trackType: " + trackType + " chunk count: " + mediaChunks.size() - + " primary sample write: "+sampleQueues[primarySampleQueueIndex].getWriteIndex() - + " primary sample read: "+sampleQueues[primarySampleQueueIndex].getReadIndex() - ); - for (int i=0; i firstSamples[i]; } return haveRead; @@ -838,13 +834,23 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; loadable.startTimeUs, loadable.endTimeUs); if (!released) { - resetSampleQueues(); + if (interrupted) { + discardAfterInterrupt(); + } + else { + resetSampleQueues(); + } if (enabledTrackGroupCount > 0) { callback.onContinueLoadingRequested(this); } } } + private void discardAfterInterrupt() { + interrupted = false; + upstreamDiscard(); + } + @Override public LoadErrorAction onLoadError( Chunk loadable, @@ -938,11 +944,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; mediaChunks.add(chunk); chunk.init(this); - HlsMediaChunk loadingChunk = findChunkMatching(chunk.uid); - for (int i=0; i < sampleQueues.length; i++) { - SampleQueue sampleQueue = sampleQueues[i]; + for (int i = 0; i < sampleQueues.length; i++) { + HlsSampleQueue sampleQueue = sampleQueues[i]; sampleQueue.setSourceChunk(chunk); - loadingChunk.setFirstSampleIndex(i, sampleQueue.getWriteIndex()); + chunk.setFirstSampleIndex(i, sampleQueue.getWriteIndex()); } if (chunk.shouldSpliceIn) { for (SampleQueue sampleQueue : sampleQueues) { @@ -1039,8 +1044,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1); sampleQueueTrackIds[trackCount] = id; sampleQueues = Util.nullSafeArrayAppend(sampleQueues, sampleQueue); - HlsMediaChunk mediaChunk = findChunkMatching(sourceChunk.uid); - mediaChunk.setFirstSampleIndex(trackCount, 0); + sourceChunk.setFirstSampleIndex(trackCount, sampleQueue.getWriteIndex()); sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1); sampleQueueIsAudioVideoFlags[trackCount] = isAudioVideo; haveAudioVideoSampleQueues |= sampleQueueIsAudioVideoFlags[trackCount]; @@ -1131,17 +1135,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Internal methods. - private HlsMediaChunk findChunkMatching(int chunkUid) { - ListIterator iter = mediaChunks.listIterator(mediaChunks.size()); - while (iter.hasPrevious()) { - HlsMediaChunk chunk = (HlsMediaChunk) iter.previous(); - if (chunk.uid == chunkUid) { - return chunk; - } - } - return null; - } - private void updateSampleStreams(@NullableType SampleStream[] streams) { hlsSampleStreams.clear(); for (@Nullable SampleStream stream : streams) { @@ -1192,20 +1185,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; buildTracksFromSampleStreams(); setIsPrepared(); - Log.d(TAG, "Wrapper prepared - trackType: " + trackType + " tracks: " + trackGroups.length + " sample queues: " + sampleQueues.length + " sample streams: " + hlsSampleStreams.size()); - - for (int i = 0; i < trackGroups.length; i++) { - TrackGroup group = trackGroups.get(i); - int sampleQueueIndex = this.trackGroupToSampleQueueIndex[i]; - if (sampleQueueIndex == C.INDEX_UNSET) { - Log.d(TAG, " track group " + i + " is unmapped, tracks: " + group.length); - } else { - Log.d(TAG, " track group " + i + " is maped to sample queue: " + sampleQueueIndex + " ,tracks: " + group.length); - for (int j=0; j Date: Fri, 15 May 2020 11:55:24 +0100 Subject: [PATCH 0255/1052] Remove set timeout on release() and setSurface() Removes the experimental methods to set a timeout when releasing the player and setting the surface. PiperOrigin-RevId: 311703988 --- .../google/android/exoplayer2/ExoPlayer.java | 20 --- .../android/exoplayer2/ExoPlayerImpl.java | 23 +--- .../exoplayer2/ExoPlayerImplInternal.java | 85 ++---------- .../android/exoplayer2/PlayerMessage.java | 45 ------- .../android/exoplayer2/PlayerMessageTest.java | 125 ------------------ 5 files changed, 12 insertions(+), 286 deletions(-) delete mode 100644 library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b4cd9a399d..89a28bd764 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -149,8 +149,6 @@ public interface ExoPlayer extends Player { private SeekParameters seekParameters; private boolean pauseAtEndOfMediaItems; private boolean buildCalled; - - private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; /** @@ -215,20 +213,6 @@ public interface ExoPlayer extends Player { clock = Clock.DEFAULT; } - /** - * Set a limit on the time a call to {@link ExoPlayer#release()} can spend. If a call to {@link - * ExoPlayer#release()} takes more than {@code timeoutMs} milliseconds to complete, the player - * will raise an error via {@link Player.EventListener#onPlayerError}. - * - *

    This method is experimental, and will be renamed or removed in a future release. - * - * @param timeoutMs The time limit in milliseconds, or 0 for no limit. - */ - public Builder experimental_setReleaseTimeoutMs(long timeoutMs) { - releaseTimeoutMs = timeoutMs; - return this; - } - /** * Sets whether the player should throw when it detects it's stuck buffering. * @@ -405,10 +389,6 @@ public interface ExoPlayer extends Player { pauseAtEndOfMediaItems, clock, looper); - - if (releaseTimeoutMs > 0) { - player.experimental_setReleaseTimeoutMs(releaseTimeoutMs); - } if (throwWhenStuckBuffering) { player.experimental_throwWhenStuckBuffering(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 26357a18dc..e98da39a10 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -45,7 +45,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeoutException; /** * An {@link ExoPlayer} implementation. Instances can be obtained from {@link ExoPlayer.Builder}. @@ -178,20 +177,6 @@ import java.util.concurrent.TimeoutException; internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); } - /** - * Set a limit on the time a call to {@link #release()} can spend. If a call to {@link #release()} - * takes more than {@code timeoutMs} milliseconds to complete, the player will raise an error via - * {@link Player.EventListener#onPlayerError}. - * - *

    This method is experimental, and will be renamed or removed in a future release. It should - * only be called before the player is used. - * - * @param timeoutMs The time limit in milliseconds, or 0 for no limit. - */ - public void experimental_setReleaseTimeoutMs(long timeoutMs) { - internalPlayer.experimental_setReleaseTimeoutMs(timeoutMs); - } - /** * Configures the player to throw when it detects it's stuck buffering. * @@ -690,13 +675,7 @@ import java.util.concurrent.TimeoutException; Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + ExoPlayerLibraryInfo.registeredModules() + "]"); - if (!internalPlayer.release()) { - notifyListeners( - listener -> - listener.onPlayerError( - ExoPlaybackException.createForUnexpected( - new RuntimeException(new TimeoutException("Player release timed out."))))); - } + internalPlayer.release(); applicationHandler.removeCallbacksAndMessages(null); playbackInfo = getResetPlaybackInfo( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 53c8a5d080..5d698b8f66 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -133,8 +133,6 @@ import java.util.concurrent.atomic.AtomicBoolean; private long rendererPositionUs; private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; - - private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; public ExoPlayerImplInternal( @@ -191,10 +189,6 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - public void experimental_setReleaseTimeoutMs(long releaseTimeoutMs) { - this.releaseTimeoutMs = releaseTimeoutMs; - } - public void experimental_throwWhenStuckBuffering() { throwWhenStuckBuffering = true; } @@ -322,23 +316,23 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - public synchronized boolean release() { + public synchronized void release() { if (released || !internalPlaybackThread.isAlive()) { - return true; + return; } - handler.sendEmptyMessage(MSG_RELEASE); - try { - if (releaseTimeoutMs > 0) { - waitUntilReleased(releaseTimeoutMs); - } else { - waitUntilReleased(); + boolean wasInterrupted = false; + while (!released) { + try { + wait(); + } catch (InterruptedException e) { + wasInterrupted = true; } - } catch (InterruptedException e) { + } + if (wasInterrupted) { + // Restore the interrupted status. Thread.currentThread().interrupt(); } - - return released; } public Looper getPlaybackLooper() { @@ -504,63 +498,6 @@ import java.util.concurrent.atomic.AtomicBoolean; // Private methods. - /** - * Blocks the current thread until {@link #releaseInternal()} is executed on the playback Thread. - * - *

    If the current thread is interrupted while waiting for {@link #releaseInternal()} to - * complete, this method will delay throwing the {@link InterruptedException} to ensure that the - * underlying resources have been released, and will an {@link InterruptedException} after - * {@link #releaseInternal()} is complete. - * - * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for - * {@link #releaseInternal()} to complete. - */ - private synchronized void waitUntilReleased() throws InterruptedException { - InterruptedException interruptedException = null; - while (!released) { - try { - wait(); - } catch (InterruptedException e) { - interruptedException = e; - } - } - - if (interruptedException != null) { - throw interruptedException; - } - } - - /** - * Blocks the current thread until {@link #releaseInternal()} is performed on the playback Thread - * or the specified amount of time has elapsed. - * - *

    If the current thread is interrupted while waiting for {@link #releaseInternal()} to - * complete, this method will delay throwing the {@link InterruptedException} to ensure that the - * underlying resources have been released or the operation timed out, and will throw an {@link - * InterruptedException} afterwards. - * - * @param timeoutMs the time in milliseconds to wait for {@link #releaseInternal()} to complete. - * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for - * {@link #releaseInternal()} to complete. - */ - private synchronized void waitUntilReleased(long timeoutMs) throws InterruptedException { - long deadlineMs = clock.elapsedRealtime() + timeoutMs; - long remainingMs = timeoutMs; - InterruptedException interruptedException = null; - while (!released && remainingMs > 0) { - try { - wait(remainingMs); - } catch (InterruptedException e) { - interruptedException = e; - } - remainingMs = deadlineMs - clock.elapsedRealtime(); - } - - if (interruptedException != null) { - throw interruptedException; - } - } - private void setState(int state) { if (playbackInfo.playbackState != state) { playbackInfo = playbackInfo.copyWithPlaybackState(state); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java index be7c7ce973..9837cb59da 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java @@ -17,10 +17,7 @@ package com.google.android.exoplayer2; import android.os.Handler; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Clock; -import java.util.concurrent.TimeoutException; /** * Defines a player message which can be sent with a {@link Sender} and received by a {@link @@ -292,28 +289,6 @@ public final class PlayerMessage { return isDelivered; } - /** - * Blocks until after the message has been delivered or the player is no longer able to deliver - * the message or the specified waiting time elapses. - * - *

    Note that this method can't be called if the current thread is the same thread used by the - * message handler set with {@link #setHandler(Handler)} as it would cause a deadlock. - * - * @param timeoutMs the maximum time to wait in milliseconds. - * @return Whether the message was delivered successfully. - * @throws IllegalStateException If this method is called before {@link #send()}. - * @throws IllegalStateException If this method is called on the same thread used by the message - * handler set with {@link #setHandler(Handler)}. - * @throws TimeoutException If the waiting time elapsed and this message has not been delivered - * and the player is still able to deliver the message. - * @throws InterruptedException If the current thread is interrupted while waiting for the message - * to be delivered. - */ - public synchronized boolean experimental_blockUntilDelivered(long timeoutMs) - throws InterruptedException, TimeoutException { - return experimental_blockUntilDelivered(timeoutMs, Clock.DEFAULT); - } - /** * Marks the message as processed. Should only be called by a {@link Sender} and may be called * multiple times. @@ -327,24 +302,4 @@ public final class PlayerMessage { isProcessed = true; notifyAll(); } - - @VisibleForTesting() - /* package */ synchronized boolean experimental_blockUntilDelivered(long timeoutMs, Clock clock) - throws InterruptedException, TimeoutException { - Assertions.checkState(isSent); - Assertions.checkState(handler.getLooper().getThread() != Thread.currentThread()); - - long deadlineMs = clock.elapsedRealtime() + timeoutMs; - long remainingMs = timeoutMs; - while (!isProcessed && remainingMs > 0) { - wait(remainingMs); - remainingMs = deadlineMs - clock.elapsedRealtime(); - } - - if (!isProcessed) { - throw new TimeoutException("Message delivery timed out."); - } - - return isDelivered; - } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java deleted file mode 100644 index 874a8c5a5a..0000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.mockito.MockitoAnnotations.initMocks; - -import android.os.Handler; -import android.os.HandlerThread; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.util.Clock; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.Mockito; - -/** Unit test for {@link PlayerMessage}. */ -@RunWith(AndroidJUnit4.class) -public class PlayerMessageTest { - - private static final long TIMEOUT_MS = 10; - - @Mock Clock clock; - private HandlerThread handlerThread; - private PlayerMessage message; - - @Before - public void setUp() { - initMocks(this); - PlayerMessage.Sender sender = (message) -> {}; - PlayerMessage.Target target = (messageType, payload) -> {}; - handlerThread = new HandlerThread("TestHandler"); - handlerThread.start(); - Handler handler = new Handler(handlerThread.getLooper()); - message = - new PlayerMessage(sender, target, Timeline.EMPTY, /* defaultWindowIndex= */ 0, handler); - } - - @After - public void tearDown() { - handlerThread.quit(); - } - - @Test - public void experimental_blockUntilDelivered_timesOut() throws Exception { - when(clock.elapsedRealtime()).thenReturn(0L).thenReturn(TIMEOUT_MS * 2); - - try { - message.send().experimental_blockUntilDelivered(TIMEOUT_MS, clock); - fail(); - } catch (TimeoutException expected) { - } - - // Ensure experimental_blockUntilDelivered() entered the blocking loop - verify(clock, Mockito.times(2)).elapsedRealtime(); - } - - @Test - public void experimental_blockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { - when(clock.elapsedRealtime()).thenReturn(0L); - - message.send().markAsProcessed(/* isDelivered= */ true); - - assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); - } - - @Test - public void experimental_blockUntilDelivered_markAsProcessedWhileBlocked_succeeds() - throws Exception { - message.send(); - - // Use a separate Thread to mark the message as processed. - CountDownLatch prepareLatch = new CountDownLatch(1); - ExecutorService executorService = Executors.newSingleThreadExecutor(); - Future future = - executorService.submit( - () -> { - prepareLatch.await(); - message.markAsProcessed(true); - return true; - }); - - when(clock.elapsedRealtime()) - .thenReturn(0L) - .then( - (invocation) -> { - // Signal the background thread to call PlayerMessage#markAsProcessed. - prepareLatch.countDown(); - return TIMEOUT_MS - 1; - }); - - try { - assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); - // Ensure experimental_blockUntilDelivered() entered the blocking loop. - verify(clock, Mockito.atLeast(2)).elapsedRealtime(); - future.get(1, TimeUnit.SECONDS); - } finally { - executorService.shutdown(); - } - } -} From f3d331c9f7ca3b50f305c08b06e9157c10117110 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 15 May 2020 15:36:06 +0100 Subject: [PATCH 0256/1052] Extend EventTime with full current position info. EventTime contains information about when an event happened and where it belongs to. Both places can be fully described using timeline, window index, media period id and position. Right now, only the information for where the event belongs to is fully contained in EventTime, whereas the time when the event happened only has the position, and none of the other information (timeline, window, period). This change adds the missing information, so that the EventTime can easily be used without having access to the Player. This also ensures Event metadata is self-contained and can be stored and reused later. issue:#7332 PiperOrigin-RevId: 311727004 --- RELEASENOTES.md | 3 + .../analytics/AnalyticsCollector.java | 4 ++ .../analytics/AnalyticsListener.java | 63 ++++++++++++++----- .../analytics/PlaybackStatsListener.java | 6 ++ .../DefaultPlaybackSessionManagerTest.java | 3 + .../analytics/PlaybackStatsListenerTest.java | 23 +++++-- 6 files changed, 82 insertions(+), 20 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b3d72a23b0..32ee83129e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -88,6 +88,9 @@ `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). + * Extend `EventTime` with more details about the current player state for + easier access + ([#7332](https://github.com/google/ExoPlayer/issues/7332)). * Video: Pass frame rate hint to `Surface.setFrameRate` on Android R devices. * Text: * Parse `` and `` tags in WebVTT subtitles (rendering is coming diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index fceaa14b73..de30f66e6c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -659,12 +659,16 @@ public class AnalyticsCollector eventPositionMs = timeline.isEmpty() ? 0 : timeline.getWindow(windowIndex, window).getDefaultPositionMs(); } + @Nullable MediaPeriodInfo currentInfo = mediaPeriodQueueTracker.getCurrentPlayerMediaPeriod(); return new EventTime( realtimeMs, timeline, windowIndex, mediaPeriodId, eventPositionMs, + player.getCurrentTimeline(), + player.getCurrentWindowIndex(), + currentInfo == null ? null : currentInfo.mediaPeriodId, player.getCurrentPosition(), player.getTotalBufferedDuration()); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java index 0b841ab543..d798702f43 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -56,7 +56,7 @@ public interface AnalyticsListener { */ public final long realtimeMs; - /** Timeline at the time of the event. */ + /** Most recent {@link Timeline} that contains the event position. */ public final Timeline timeline; /** @@ -66,8 +66,8 @@ public interface AnalyticsListener { public final int windowIndex; /** - * Media period identifier for the media period this event belongs to, or {@code null} if the - * event is not associated with a specific media period. + * {@link MediaPeriodId Media period identifier} for the media period this event belongs to, or + * {@code null} if the event is not associated with a specific media period. */ @Nullable public final MediaPeriodId mediaPeriodId; @@ -77,8 +77,27 @@ public interface AnalyticsListener { public final long eventPlaybackPositionMs; /** - * Position in the current timeline window ({@link Player#getCurrentWindowIndex()}) or the - * currently playing ad at the time of the event, in milliseconds. + * The current {@link Timeline} at the time of the event (equivalent to {@link + * Player#getCurrentTimeline()}). + */ + public final Timeline currentTimeline; + + /** + * The current window index in {@link #currentTimeline} at the time of the event, or the + * prospective window index if the timeline is not yet known and empty (equivalent to {@link + * Player#getCurrentWindowIndex()}). + */ + public final int currentWindowIndex; + + /** + * {@link MediaPeriodId Media period identifier} for the currently playing media period at the + * time of the event, or {@code null} if no current media period identifier is available. + */ + @Nullable public final MediaPeriodId currentMediaPeriodId; + + /** + * Position in the {@link #currentWindowIndex current timeline window} or the currently playing + * ad at the time of the event, in milliseconds. */ public final long currentPlaybackPositionMs; @@ -91,19 +110,27 @@ public interface AnalyticsListener { /** * @param realtimeMs Elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} at * the time of the event, in milliseconds. - * @param timeline Timeline at the time of the event. - * @param windowIndex Window index in the {@link #timeline} this event belongs to, or the + * @param timeline Most recent {@link Timeline} that contains the event position. + * @param windowIndex Window index in the {@code timeline} this event belongs to, or the * prospective window index if the timeline is not yet known and empty. - * @param mediaPeriodId Media period identifier for the media period this event belongs to, or - * {@code null} if the event is not associated with a specific media period. + * @param mediaPeriodId {@link MediaPeriodId Media period identifier} for the media period this + * event belongs to, or {@code null} if the event is not associated with a specific media + * period. * @param eventPlaybackPositionMs Position in the window or ad this event belongs to at the time * of the event, in milliseconds. - * @param currentPlaybackPositionMs Position in the current timeline window ({@link - * Player#getCurrentWindowIndex()}) or the currently playing ad at the time of the event, in - * milliseconds. - * @param totalBufferedDurationMs Total buffered duration from {@link - * #currentPlaybackPositionMs} at the time of the event, in milliseconds. This includes - * pre-buffered data for subsequent ads and windows. + * @param currentTimeline The current {@link Timeline} at the time of the event (equivalent to + * {@link Player#getCurrentTimeline()}). + * @param currentWindowIndex The current window index in {@code currentTimeline} at the time of + * the event, or the prospective window index if the timeline is not yet known and empty + * (equivalent to {@link Player#getCurrentWindowIndex()}). + * @param currentMediaPeriodId {@link MediaPeriodId Media period identifier} for the currently + * playing media period at the time of the event, or {@code null} if no current media period + * identifier is available. + * @param currentPlaybackPositionMs Position in the current timeline window or the currently + * playing ad at the time of the event, in milliseconds. + * @param totalBufferedDurationMs Total buffered duration from {@code currentPlaybackPositionMs} + * at the time of the event, in milliseconds. This includes pre-buffered data for subsequent + * ads and windows. */ public EventTime( long realtimeMs, @@ -111,6 +138,9 @@ public interface AnalyticsListener { int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long eventPlaybackPositionMs, + Timeline currentTimeline, + int currentWindowIndex, + @Nullable MediaPeriodId currentMediaPeriodId, long currentPlaybackPositionMs, long totalBufferedDurationMs) { this.realtimeMs = realtimeMs; @@ -118,6 +148,9 @@ public interface AnalyticsListener { this.windowIndex = windowIndex; this.mediaPeriodId = mediaPeriodId; this.eventPlaybackPositionMs = eventPlaybackPositionMs; + this.currentTimeline = currentTimeline; + this.currentWindowIndex = currentWindowIndex; + this.currentMediaPeriodId = currentMediaPeriodId; this.currentPlaybackPositionMs = currentPlaybackPositionMs; this.totalBufferedDurationMs = totalBufferedDurationMs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index 0524f4d3b1..cefe143b73 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -157,6 +157,9 @@ public final class PlaybackStatsListener /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); sessionManager.finishAllSessions(dummyEventTime); @@ -210,6 +213,9 @@ public final class PlaybackStatsListener eventTime.mediaPeriodId.windowSequenceNumber, eventTime.mediaPeriodId.adGroupIndex), /* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs), + eventTime.timeline, + eventTime.currentWindowIndex, + eventTime.currentMediaPeriodId, eventTime.currentPlaybackPositionMs, eventTime.totalBufferedDurationMs); Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java index b24135152e..a5a021c80a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -1092,6 +1092,9 @@ public final class DefaultPlaybackSessionManagerTest { windowIndex, mediaPeriodId, /* eventPlaybackPositionMs= */ 0, + timeline, + windowIndex, + mediaPeriodId, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java index c6d4a597ed..8a1f8807ea 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java @@ -44,20 +44,27 @@ public final class PlaybackStatsListenerTest { /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + /* currentTimeline= */ Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); private static final Timeline TEST_TIMELINE = new FakeTimeline(/* windowCount= */ 1); + private static final MediaSource.MediaPeriodId TEST_MEDIA_PERIOD_ID = + new MediaSource.MediaPeriodId( + TEST_TIMELINE.getPeriod(/* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true) + .uid, + /* windowSequenceNumber= */ 42); private static final AnalyticsListener.EventTime TEST_EVENT_TIME = new AnalyticsListener.EventTime( /* realtimeMs= */ 500, TEST_TIMELINE, /* windowIndex= */ 0, - new MediaSource.MediaPeriodId( - TEST_TIMELINE.getPeriod( - /* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true) - .uid, - /* windowSequenceNumber= */ 42), + TEST_MEDIA_PERIOD_ID, /* eventPlaybackPositionMs= */ 123, + TEST_TIMELINE, + /* currentWindowIndex= */ 0, + TEST_MEDIA_PERIOD_ID, /* currentPlaybackPositionMs= */ 123, /* totalBufferedDurationMs= */ 456); @@ -151,6 +158,9 @@ public final class PlaybackStatsListenerTest { /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); AnalyticsListener.EventTime eventTimeWindow1 = @@ -160,6 +170,9 @@ public final class PlaybackStatsListenerTest { /* windowIndex= */ 1, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 1, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); From 793f12da6d8559af0c5c04d27dd3f55141a0e8c0 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 15 May 2020 16:00:00 +0100 Subject: [PATCH 0257/1052] Add support for timing out ad preloading Detect stuck buffering cases in ImaAdsLoader, and discard the ad group after a timeout. This is intended to make the IMA extension more robust in the case where an ad group unexpectedly doesn't load. The timing out behavior is enabled by default but apps can choose to retain the old behavior by setting an unset timeout on ImaAdsLoader.Builder. PiperOrigin-RevId: 311729798 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 142 +++++++++++++++--- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 80 ++++++++-- .../source/ads/AdPlaybackState.java | 12 ++ .../source/ads/AdPlaybackStateTest.java | 2 + 4 files changed, 208 insertions(+), 28 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index c3ef1004f7..fc75749724 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -102,11 +102,23 @@ public final class ImaAdsLoader /** Builder for {@link ImaAdsLoader}. */ public static final class Builder { + /** + * The default duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. + * + *

    This value should be large enough not to trigger discarding the ad when it actually might + * load soon, but small enough so that user is not waiting for too long. + * + * @see #setAdPreloadTimeoutMs(long) + */ + public static final long DEFAULT_AD_PRELOAD_TIMEOUT_MS = 10 * C.MILLIS_PER_SECOND; + private final Context context; @Nullable private ImaSdkSettings imaSdkSettings; @Nullable private AdEventListener adEventListener; @Nullable private Set adUiElements; + private long adPreloadTimeoutMs; private int vastLoadTimeoutMs; private int mediaLoadTimeoutMs; private int mediaBitrate; @@ -120,6 +132,7 @@ public final class ImaAdsLoader */ public Builder(Context context) { this.context = Assertions.checkNotNull(context); + adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS; vastLoadTimeoutMs = TIMEOUT_UNSET; mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; @@ -165,6 +178,25 @@ public final class ImaAdsLoader return this; } + /** + * Sets the duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. Pass {@link + * C#TIME_UNSET} if there should be no such timeout. The default value is {@value + * DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms. + * + *

    The purpose of this timeout is to avoid playback getting stuck in the unexpected case that + * the IMA SDK does not load an ad break based on the player's reported content position. + * + * @param adPreloadTimeoutMs The timeout buffering duration in milliseconds, or {@link + * C#TIME_UNSET} for no timeout. + * @return This builder, for convenience. + */ + public Builder setAdPreloadTimeoutMs(long adPreloadTimeoutMs) { + Assertions.checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0); + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + return this; + } + /** * Sets the VAST load timeout, in milliseconds. * @@ -238,6 +270,7 @@ public final class ImaAdsLoader adTagUri, imaSdkSettings, /* adsResponse= */ null, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -260,6 +293,7 @@ public final class ImaAdsLoader /* adTagUri= */ null, imaSdkSettings, adsResponse, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -291,7 +325,12 @@ public final class ImaAdsLoader * Threshold before the end of content at which IMA is notified that content is complete if the * player buffers, in milliseconds. */ - private static final long END_OF_CONTENT_THRESHOLD_MS = 5000; + private static final long THRESHOLD_END_OF_CONTENT_MS = 5000; + /** + * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in + * milliseconds. + */ + private static final long THRESHOLD_AD_PRELOAD_MS = 4000; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -317,6 +356,7 @@ public final class ImaAdsLoader @Nullable private final Uri adTagUri; @Nullable private final String adsResponse; + private final long adPreloadTimeoutMs; private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; @@ -398,6 +438,11 @@ public final class ImaAdsLoader private long pendingContentPositionMs; /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ private boolean sentPendingContentPositionMs; + /** + * Stores the real time in milliseconds at which the player started buffering, possibly due to not + * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. + */ + private long waitingForPreloadElapsedRealtimeMs; /** * Creates a new IMA ads loader. @@ -415,6 +460,7 @@ public final class ImaAdsLoader adTagUri, /* imaSdkSettings= */ null, /* adsResponse= */ null, + /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS, /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, @@ -430,6 +476,7 @@ public final class ImaAdsLoader @Nullable Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings, @Nullable String adsResponse, + long adPreloadTimeoutMs, int vastLoadTimeoutMs, int mediaLoadTimeoutMs, int mediaBitrate, @@ -440,6 +487,7 @@ public final class ImaAdsLoader Assertions.checkArgument(adTagUri != null || adsResponse != null); this.adTagUri = adTagUri; this.adsResponse = adsResponse; + this.adPreloadTimeoutMs = adPreloadTimeoutMs; this.vastLoadTimeoutMs = vastLoadTimeoutMs; this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; @@ -473,6 +521,7 @@ public final class ImaAdsLoader fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; fakeContentProgressOffsetMs = C.TIME_UNSET; pendingContentPositionMs = C.TIME_UNSET; + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; adPlaybackState = AdPlaybackState.NONE; @@ -636,6 +685,7 @@ public final class ImaAdsLoader imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; + stopUpdatingAdProgress(); imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; @@ -737,6 +787,19 @@ public final class ImaAdsLoader if (DEBUG) { Log.d(TAG, "Content progress: " + videoProgressUpdate); } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } + return videoProgressUpdate; } @@ -779,10 +842,15 @@ public final class ImaAdsLoader // Drop events after release. return; } - int adGroupIndex = getAdGroupIndex(adPodInfo); + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA will + // timeout after its media load timeout. + return; + } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = @@ -926,9 +994,34 @@ public final class ImaAdsLoader @Override public void onPlaybackStateChanged(@Player.State int playbackState) { + @Nullable Player player = this.player; if (adsManager == null || player == null) { return; } + + if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) { + // Check whether we are waiting for an ad to preload. + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count != C.LENGTH_UNSET + && adGroup.count != 0 + && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + // An ad is available already so we must be buffering for some other reason. + return; + } + long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + long timeUntilAdMs = adGroupTimeMs - contentPositionMs; + if (timeUntilAdMs < adPreloadTimeoutMs) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); } @@ -1228,6 +1321,10 @@ public final class ImaAdsLoader Assertions.checkNotNull(imaAdInfo); int adGroupIndex = imaAdInfo.adGroupIndex; int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. + return; + } adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); @@ -1242,19 +1339,11 @@ public final class ImaAdsLoader return; } - // TODO: Once IMA signals which ad group failed to load, clean up this code. - long playerPositionMs = player.getContentPosition(); - int adGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + // TODO: Once IMA signals which ad group failed to load, remove this call. + int adGroupIndex = getLoadingAdGroupIndex(); if (adGroupIndex == C.INDEX_UNSET) { - adGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndex == C.INDEX_UNSET) { - // The error doesn't seem to relate to any ad group so give up handling it. - return; - } + Log.w(TAG, "Unable to determine ad group index for ad group load error", error); + return; } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; @@ -1321,7 +1410,7 @@ public final class ImaAdsLoader if (!sentContentComplete && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && positionMs + END_OF_CONTENT_THRESHOLD_MS >= contentDurationMs) { + && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { Log.d(TAG, "adsLoader.contentComplete"); @@ -1359,7 +1448,7 @@ public final class ImaAdsLoader } } - private int getAdGroupIndex(AdPodInfo adPodInfo) { + private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { if (adPodInfo.getPodIndex() == -1) { // This is a postroll ad. return adPlaybackState.adGroupCount - 1; @@ -1375,6 +1464,23 @@ public final class ImaAdsLoader throw new IllegalStateException("Failed to find cue point"); } + /** + * Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is + * no such ad group. + */ + private int getLoadingAdGroupIndex() { + long playerPositionUs = + C.msToUs(getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period)); + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + playerPositionUs, C.msToUs(contentDurationMs)); + } + return adGroupIndex; + } + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; @@ -1388,7 +1494,9 @@ public final class ImaAdsLoader Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); return contentWindowPositionMs - - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } private static long[] getAdGroupTimesUs(List cuePoints) { diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 6405583bf1..97e467fafd 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -57,6 +57,7 @@ import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -73,6 +74,7 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.mockito.stubbing.Answer; +import org.robolectric.shadows.ShadowSystemClock; /** Tests for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) @@ -88,7 +90,7 @@ public final class ImaAdsLoaderTest { private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; + private static final long[][] ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -140,14 +142,14 @@ public final class ImaAdsLoaderTest { @Test public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test public void start_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); @@ -157,7 +159,7 @@ public final class ImaAdsLoaderTest { @Test public void start_withPlaceholderContent_initializedAdsLoader() { Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); - setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); // We'll only create the rendering settings when initializing the ads loader. @@ -166,26 +168,26 @@ public final class ImaAdsLoaderTest { @Test public void start_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ 0) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void startAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test public void startAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -212,7 +214,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -245,14 +247,70 @@ public final class ImaAdsLoaderTest { .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + long adGroupTimeUs = + adGroupPositionInWindowUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupTimeUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance before the timeout and simulating polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US)); + } + + @Test + public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + long adGroupTimeUs = + adGroupPositionInWindowUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupTimeUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance past the timeout and simulate polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index dee63d819e..783a452b1a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -360,6 +360,18 @@ public final class AdPlaybackState { return index < adGroupTimesUs.length ? index : C.INDEX_UNSET; } + /** Returns whether the specified ad has been marked as in {@link #AD_STATE_ERROR}. */ + public boolean isAdInErrorState(int adGroupIndex, int adIndexInAdGroup) { + if (adGroupIndex >= adGroups.length) { + return false; + } + AdGroup adGroup = adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET || adIndexInAdGroup >= adGroup.count) { + return false; + } + return adGroup.states[adIndexInAdGroup] == AdPlaybackState.AD_STATE_ERROR; + } + /** * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. * The ad count must be greater than zero. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 5b7713a835..3a253b2976 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -64,7 +64,9 @@ public final class AdPlaybackStateTest { assertThat(state.adGroups[0].uris[0]).isNull(); assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)).isTrue(); assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } @Test From f6d0e34ceaf21b35b3da7901d54c64f6d9d9f259 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 15 May 2020 18:23:02 +0100 Subject: [PATCH 0258/1052] Fix Extractor.read throws documentation PiperOrigin-RevId: 311755157 --- .../java/com/google/android/exoplayer2/extractor/Extractor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java index d1371d56b6..c3920ca7da 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java @@ -96,7 +96,7 @@ public interface Extractor { * @param seekPosition If {@link #RESULT_SEEK} is returned, this holder is updated to hold the * position of the required data. * @return One of the {@code RESULT_} values defined in this interface. - * @throws IOException If an error occurred reading from the input. + * @throws IOException If an error occurred reading from or parsing the input. */ @ReadResult int read(ExtractorInput input, PositionHolder seekPosition) throws IOException; From ceddc60296a78dd06857aba7b7e44112e725e9fd Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 08:46:11 +0100 Subject: [PATCH 0259/1052] Publish MediaCodec-based renderer tests Switch to snapshot Robolectric to pick up the latest version of shadows required by MediaCodecVideoRendererTest and MediaCodecAudioRendererTest. PiperOrigin-RevId: 312030332 --- build.gradle | 1 + constants.gradle | 2 +- .../audio/MediaCodecAudioRendererTest.java | 150 ++++++ .../video/MediaCodecVideoRendererTest.java | 499 ++++++++++++++++++ 4 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java diff --git a/build.gradle b/build.gradle index d520925fb0..c62a97b6e3 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ allprojects { repositories { google() jcenter() + maven { url "https://oss.sonatype.org/content/repositories/snapshots" } } project.ext { exoplayerPublishEnabled = false diff --git a/constants.gradle b/constants.gradle index 1a7840588f..f3bebf6038 100644 --- a/constants.gradle +++ b/constants.gradle @@ -23,7 +23,7 @@ project.ext { junitVersion = '4.13-rc-2' guavaVersion = '28.2-android' mockitoVersion = '2.25.0' - robolectricVersion = '4.3.1' + robolectricVersion = '4.4-SNAPSHOT' checkerframeworkVersion = '2.5.0' jsr305Version = '3.0.2' kotlinAnnotationsVersion = '1.3.70' diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java new file mode 100644 index 0000000000..48fbdf5564 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.os.SystemClock; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.testutil.FakeSampleStream; +import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link MediaCodecAudioRenderer} */ +@RunWith(AndroidJUnit4.class) +public class MediaCodecAudioRendererTest { + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private static final Format AUDIO_AAC = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(2) + .setSampleRate(44100) + .setEncoderDelay(100) + .setEncoderPadding(150) + .build(); + + private MediaCodecAudioRenderer mediaCodecAudioRenderer; + + @Mock private AudioSink audioSink; + + @Before + public void setUp() throws Exception { + // audioSink isEnded can always be true because the MediaCodecAudioRenderer isEnded = + // super.isEnded && audioSink.isEnded. + when(audioSink.isEnded()).thenReturn(true); + + when(audioSink.handleBuffer(any(), anyLong(), anyInt())).thenReturn(true); + + MediaCodecSelector mediaCodecSelector = + new MediaCodecSelector() { + @Override + public List getDecoderInfos( + String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) { + return Collections.singletonList( + MediaCodecInfo.newInstance( + /* name= */ "name", + /* mimeType= */ mimeType, + /* codecMimeType= */ mimeType, + /* capabilities= */ null, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false)); + } + }; + + mediaCodecAudioRenderer = + new MediaCodecAudioRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* enableDecoderFallback= */ false, + /* eventHandler= */ null, + /* eventListener= */ null, + audioSink) { + @Override + protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) + throws DecoderQueryException { + return RendererCapabilities.create(FORMAT_HANDLED); + } + }; + } + + @Test + public void render_configuresAudioSink() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ AUDIO_AAC, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + + mediaCodecAudioRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {AUDIO_AAC}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + + mediaCodecAudioRenderer.start(); + mediaCodecAudioRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.setCurrentStreamFinal(); + + int positionUs = 500; + do { + mediaCodecAudioRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 250; + } while (!mediaCodecAudioRenderer.isEnded()); + + verify(audioSink) + .configure( + AUDIO_AAC.pcmEncoding, + AUDIO_AAC.channelCount, + AUDIO_AAC.sampleRate, + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null, + AUDIO_AAC.encoderDelay, + AUDIO_AAC.encoderPadding); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java new file mode 100644 index 0000000000..4ec7bd8043 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java @@ -0,0 +1,499 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.video; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.graphics.SurfaceTexture; +import android.os.Handler; +import android.os.SystemClock; +import android.view.Surface; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.testutil.FakeSampleStream; +import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.annotation.LooperMode; + +/** Unit test for {@link MediaCodecVideoRenderer}. */ +@RunWith(AndroidJUnit4.class) +@LooperMode(LooperMode.Mode.LEGACY) +public class MediaCodecVideoRendererTest { + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private static final Format VIDEO_H264 = + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setWidth(1920) + .setHeight(1080) + .build(); + + private MediaCodecVideoRenderer mediaCodecVideoRenderer; + @Nullable private Format currentOutputFormat; + + @Mock private VideoRendererEventListener eventListener; + + @Before + public void setUp() throws Exception { + MediaCodecSelector mediaCodecSelector = + new MediaCodecSelector() { + @Override + public List getDecoderInfos( + String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) { + return Collections.singletonList( + MediaCodecInfo.newInstance( + /* name= */ "name", + /* mimeType= */ mimeType, + /* codecMimeType= */ mimeType, + /* capabilities= */ null, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false)); + } + }; + + mediaCodecVideoRenderer = + new MediaCodecVideoRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* allowedJoiningTimeMs= */ 0, + /* eventHandler= */ new Handler(), + /* eventListener= */ eventListener, + /* maxDroppedFramesToNotify= */ 1) { + @Override + @Capabilities + protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) + throws DecoderQueryException { + return RendererCapabilities.create(FORMAT_HANDLED); + } + + @Override + protected void onOutputFormatChanged(Format outputFormat) { + super.onOutputFormatChanged(outputFormat); + currentOutputFormat = outputFormat; + } + }; + + mediaCodecVideoRenderer.handleMessage( + Renderer.MSG_SET_SURFACE, new Surface(new SurfaceTexture(/* texName= */ 0))); + } + + @Test + public void render_dropsLateBuffer() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50_000, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), // First buffer. + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), // Late buffer. + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), // Last buffer. + FakeSampleStreamItem.END_OF_STREAM_ITEM); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + + mediaCodecVideoRenderer.start(); + mediaCodecVideoRenderer.render(0, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.render(40_000, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + int posUs = 80_001; // Ensures buffer will be 30_001us late. + while (!mediaCodecVideoRenderer.isEnded()) { + mediaCodecVideoRenderer.render(posUs, SystemClock.elapsedRealtime() * 1000); + posUs += 40_000; + } + + verify(eventListener).onDroppedFrames(eq(1), anyLong()); + } + + @Test + public void render_sendsVideoSizeChangeWithCurrentFormatValues() throws Exception { + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 0, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM), + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + mediaCodecVideoRenderer.start(); + + int positionUs = 0; + do { + mediaCodecVideoRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 10; + } while (!mediaCodecVideoRenderer.isEnded()); + + verify(eventListener) + .onVideoSizeChanged( + VIDEO_H264.width, + VIDEO_H264.height, + VIDEO_H264.rotationDegrees, + VIDEO_H264.pixelWidthHeightRatio); + } + + @Test + public void + render_withMultipleQueued_sendsVideoSizeChangedWithCorrectPixelAspectRatioWhenMultipleQueued() + throws Exception { + Format pAsp1 = VIDEO_H264.buildUpon().setPixelWidthHeightRatio(1f).build(); + Format pAsp2 = VIDEO_H264.buildUpon().setPixelWidthHeightRatio(2f).build(); + Format pAsp3 = VIDEO_H264.buildUpon().setPixelWidthHeightRatio(3f).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ pAsp1, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 5000, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {pAsp1, pAsp2, pAsp3}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + mediaCodecVideoRenderer.start(); + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + + fakeSampleStream.addFakeSampleStreamItem(new FakeSampleStreamItem(pAsp2)); + fakeSampleStream.addFakeSampleStreamItem( + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem( + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem(new FakeSampleStreamItem(pAsp3)); + fakeSampleStream.addFakeSampleStreamItem( + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem( + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem(FakeSampleStreamItem.END_OF_STREAM_ITEM); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + + int pos = 500; + do { + mediaCodecVideoRenderer.render(/* positionUs= */ pos, SystemClock.elapsedRealtime() * 1000); + pos += 250; + } while (!mediaCodecVideoRenderer.isEnded()); + + InOrder orderVerifier = inOrder(eventListener); + orderVerifier.verify(eventListener).onVideoSizeChanged(anyInt(), anyInt(), anyInt(), eq(1f)); + orderVerifier.verify(eventListener).onVideoSizeChanged(anyInt(), anyInt(), anyInt(), eq(2f)); + orderVerifier.verify(eventListener).onVideoSizeChanged(anyInt(), anyInt(), anyInt(), eq(3f)); + orderVerifier.verifyNoMoreInteractions(); + } + + @Test + public void render_includingResetPosition_keepsOutputFormatInVideoFrameMetadataListener() + throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + + mediaCodecVideoRenderer.start(); + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.resetPosition(0); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + fakeSampleStream.addFakeSampleStreamItem( + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem(FakeSampleStreamItem.END_OF_STREAM_ITEM); + int positionUs = 10; + do { + mediaCodecVideoRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 10; + } while (!mediaCodecVideoRenderer.isEnded()); + + assertThat(currentOutputFormat).isEqualTo(VIDEO_H264); + } + + @Test + public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + + verify(eventListener).onRenderedFirstFrame(any()); + } + + @Test + public void enable_withoutMayRenderStartOfStream_doesNotRenderFirstFrameBeforeStart() + throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + + verify(eventListener, never()).onRenderedFirstFrame(any()); + } + + @Test + public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + mediaCodecVideoRenderer.start(); + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + + verify(eventListener).onRenderedFirstFrame(any()); + } + + @Test + public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { + FakeSampleStream fakeSampleStream1 = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + FakeSampleStream fakeSampleStream2 = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + mediaCodecVideoRenderer.start(); + + boolean replacedStream = false; + for (int i = 0; i <= 10; i++) { + mediaCodecVideoRenderer.render( + /* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); + if (!replacedStream && mediaCodecVideoRenderer.hasReadStreamToEnd()) { + mediaCodecVideoRenderer.replaceStream( + new Format[] {VIDEO_H264}, fakeSampleStream2, /* offsetUs= */ 100); + replacedStream = true; + } + } + + verify(eventListener, times(2)).onRenderedFirstFrame(any()); + } + + @Test + public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { + FakeSampleStream fakeSampleStream1 = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + FakeSampleStream fakeSampleStream2 = + new FakeSampleStream( + /* format= */ VIDEO_H264, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + + boolean replacedStream = false; + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render( + /* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); + if (!replacedStream && mediaCodecVideoRenderer.hasReadStreamToEnd()) { + mediaCodecVideoRenderer.replaceStream( + new Format[] {VIDEO_H264}, fakeSampleStream2, /* offsetUs= */ 100); + replacedStream = true; + } + } + + verify(eventListener).onRenderedFirstFrame(any()); + + // Render to streamOffsetUs and verify the new first frame gets rendered. + mediaCodecVideoRenderer.render(/* positionUs= */ 100, SystemClock.elapsedRealtime() * 1000); + + verify(eventListener, times(2)).onRenderedFirstFrame(any()); + } + + @Test + public void onVideoFrameProcessingOffset_isCalledAfterOutputFormatChanges() + throws ExoPlaybackException { + Format mp4Uhd = VIDEO_H264.buildUpon().setWidth(3840).setHeight(2160).build(); + byte[] sampleData = new byte[0]; + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ mp4Uhd, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(mp4Uhd), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(VIDEO_H264), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(mp4Uhd), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(VIDEO_H264), + new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {mp4Uhd}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + + mediaCodecVideoRenderer.setCurrentStreamFinal(); + mediaCodecVideoRenderer.start(); + + int positionUs = 10; + do { + mediaCodecVideoRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 10; + } while (!mediaCodecVideoRenderer.isEnded()); + mediaCodecVideoRenderer.stop(); + + InOrder orderVerifier = inOrder(eventListener); + orderVerifier.verify(eventListener).onVideoFrameProcessingOffset(anyLong(), eq(1), eq(mp4Uhd)); + orderVerifier + .verify(eventListener) + .onVideoFrameProcessingOffset(anyLong(), eq(2), eq(VIDEO_H264)); + orderVerifier.verify(eventListener).onVideoFrameProcessingOffset(anyLong(), eq(3), eq(mp4Uhd)); + orderVerifier + .verify(eventListener) + .onVideoFrameProcessingOffset(anyLong(), eq(1), eq(VIDEO_H264)); + orderVerifier.verifyNoMoreInteractions(); + } +} From 8188c292797eca46197683eba13a2e267d4bdae9 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 18 May 2020 09:27:34 +0100 Subject: [PATCH 0260/1052] Shorten the string form of AnalyticsCollectorTest internal objects This makes test failures with lists of items much easier to read PiperOrigin-RevId: 312035040 --- .../analytics/AnalyticsCollectorTest.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 1d22984f84..4d4d25e486 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -1255,15 +1255,21 @@ public final class AnalyticsCollectorTest { @Override public String toString() { return mediaPeriodId != null - ? "Event{" + ? "{" + "window=" + windowIndex - + ", period=" - + mediaPeriodId.periodUid + ", sequence=" + mediaPeriodId.windowSequenceNumber + + (mediaPeriodId.adGroupIndex != C.INDEX_UNSET + ? ", adGroup=" + + mediaPeriodId.adGroupIndex + + ", adIndexInGroup=" + + mediaPeriodId.adIndexInAdGroup + : "") + + ", period.hashCode=" + + mediaPeriodId.periodUid.hashCode() + '}' - : "Event{" + "window=" + windowIndex + ", period = null}"; + : "{" + "window=" + windowIndex + ", period = null}"; } @Override @@ -1534,12 +1540,7 @@ public final class AnalyticsCollectorTest { @Override public String toString() { - return "ReportedEvent{" - + "type=" - + eventType - + ", windowAndPeriodId=" - + eventWindowAndPeriodId - + '}'; + return "{" + "type=" + eventType + ", windowAndPeriodId=" + eventWindowAndPeriodId + '}'; } } } From cda9417aa6a2bbf2ae933c2b5b86aa3971bb99d9 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 18 May 2020 10:32:23 +0100 Subject: [PATCH 0261/1052] Allow MP3 files to play with size greater than 2GB. Issue:#7337 PiperOrigin-RevId: 312042768 --- RELEASENOTES.md | 13 ++++++++----- .../exoplayer2/extractor/mp3/XingSeeker.java | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 32ee83129e..d99597535c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -158,11 +158,14 @@ marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * Fix assertion failure in `SampleQueue` when playing DASH streams with EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). -* MP3: Add `IndexSeeker` for accurate seeks in VBR streams - ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker is - enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the `Mp3Extractor`. It may - require to scan a significant portion of the file for seeking, which may be - costly on large files. +* MP3: + * Add `IndexSeeker` for accurate seeks in VBR streams + ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker + is enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the `Mp3Extractor`. + It may require to scan a significant portion of the file for seeking, + which may be costly on large files. + * Allow MP3 files with XING headers that are larger than 2GB to be played + ([#7337](https://github.com/google/ExoPlayer/issues/7337)). * MP4: Store the Android capture frame rate only in `Format.metadata`. `Format.frameRate` now stores the calculated frame rate. * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java index 9f31fba25e..d95721be5d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java @@ -64,7 +64,7 @@ import com.google.android.exoplayer2.util.Util; return new XingSeeker(position, mpegAudioHeader.frameSize, durationUs); } - long dataSize = frame.readUnsignedIntToInt(); + long dataSize = frame.readUnsignedInt(); long[] tableOfContents = new long[100]; for (int i = 0; i < 100; i++) { tableOfContents[i] = frame.readUnsignedByte(); From b667a0e7e2a6d6d5f6995f9f2f2d13dfacd52774 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 18 May 2020 12:53:23 +0100 Subject: [PATCH 0262/1052] Rename closed caption variables to be non-608 specific This is the rename-only part of https://github.com/google/ExoPlayer/pull/7370 PiperOrigin-RevId: 312057896 --- .../source/dash/DashMediaPeriod.java | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index e1a441f36f..7be4f86254 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -488,14 +488,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int primaryGroupCount = groupedAdaptationSetIndices.length; boolean[] primaryGroupHasEventMessageTrackFlags = new boolean[primaryGroupCount]; - Format[][] primaryGroupCea608TrackFormats = new Format[primaryGroupCount][]; + Format[][] primaryGroupClosedCaptionTrackFormats = new Format[primaryGroupCount][]; int totalEmbeddedTrackGroupCount = identifyEmbeddedTracks( primaryGroupCount, adaptationSets, groupedAdaptationSetIndices, primaryGroupHasEventMessageTrackFlags, - primaryGroupCea608TrackFormats); + primaryGroupClosedCaptionTrackFormats); int totalGroupCount = primaryGroupCount + totalEmbeddedTrackGroupCount + eventStreams.size(); TrackGroup[] trackGroups = new TrackGroup[totalGroupCount]; @@ -508,7 +508,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; groupedAdaptationSetIndices, primaryGroupCount, primaryGroupHasEventMessageTrackFlags, - primaryGroupCea608TrackFormats, + primaryGroupClosedCaptionTrackFormats, trackGroups, trackGroupInfos); @@ -616,8 +616,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * same primary group, grouped in primary track groups order. * @param primaryGroupHasEventMessageTrackFlags An output array to be filled with flags indicating * whether each of the primary track groups contains an embedded event message track. - * @param primaryGroupCea608TrackFormats An output array to be filled with track formats for - * CEA-608 tracks embedded in each of the primary track groups. + * @param primaryGroupClosedCaptionTrackFormats An output array to be filled with track formats + * for closed caption tracks embedded in each of the primary track groups. * @return Total number of embedded track groups. */ private static int identifyEmbeddedTracks( @@ -625,16 +625,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; List adaptationSets, int[][] groupedAdaptationSetIndices, boolean[] primaryGroupHasEventMessageTrackFlags, - Format[][] primaryGroupCea608TrackFormats) { + Format[][] primaryGroupClosedCaptionTrackFormats) { int numEmbeddedTrackGroups = 0; for (int i = 0; i < primaryGroupCount; i++) { if (hasEventMessageTrack(adaptationSets, groupedAdaptationSetIndices[i])) { primaryGroupHasEventMessageTrackFlags[i] = true; numEmbeddedTrackGroups++; } - primaryGroupCea608TrackFormats[i] = - getCea608TrackFormats(adaptationSets, groupedAdaptationSetIndices[i]); - if (primaryGroupCea608TrackFormats[i].length != 0) { + primaryGroupClosedCaptionTrackFormats[i] = + getClosedCaptionTrackFormats(adaptationSets, groupedAdaptationSetIndices[i]); + if (primaryGroupClosedCaptionTrackFormats[i].length != 0) { numEmbeddedTrackGroups++; } } @@ -647,7 +647,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int[][] groupedAdaptationSetIndices, int primaryGroupCount, boolean[] primaryGroupHasEventMessageTrackFlags, - Format[][] primaryGroupCea608TrackFormats, + Format[][] primaryGroupClosedCaptionTrackFormats, TrackGroup[] trackGroups, TrackGroupInfo[] trackGroupInfos) { int trackGroupCount = 0; @@ -673,8 +673,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int primaryTrackGroupIndex = trackGroupCount++; int eventMessageTrackGroupIndex = primaryGroupHasEventMessageTrackFlags[i] ? trackGroupCount++ : C.INDEX_UNSET; - int cea608TrackGroupIndex = - primaryGroupCea608TrackFormats[i].length != 0 ? trackGroupCount++ : C.INDEX_UNSET; + int closedCaptionTrackGroupIndex = + primaryGroupClosedCaptionTrackFormats[i].length != 0 ? trackGroupCount++ : C.INDEX_UNSET; trackGroups[primaryTrackGroupIndex] = new TrackGroup(formats); trackGroupInfos[primaryTrackGroupIndex] = @@ -683,7 +683,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; adaptationSetIndices, primaryTrackGroupIndex, eventMessageTrackGroupIndex, - cea608TrackGroupIndex); + closedCaptionTrackGroupIndex); if (eventMessageTrackGroupIndex != C.INDEX_UNSET) { Format format = new Format.Builder() @@ -694,10 +694,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackGroupInfos[eventMessageTrackGroupIndex] = TrackGroupInfo.embeddedEmsgTrack(adaptationSetIndices, primaryTrackGroupIndex); } - if (cea608TrackGroupIndex != C.INDEX_UNSET) { - trackGroups[cea608TrackGroupIndex] = new TrackGroup(primaryGroupCea608TrackFormats[i]); - trackGroupInfos[cea608TrackGroupIndex] = - TrackGroupInfo.embeddedCea608Track(adaptationSetIndices, primaryTrackGroupIndex); + if (closedCaptionTrackGroupIndex != C.INDEX_UNSET) { + trackGroups[closedCaptionTrackGroupIndex] = + new TrackGroup(primaryGroupClosedCaptionTrackFormats[i]); + trackGroupInfos[closedCaptionTrackGroupIndex] = + TrackGroupInfo.embeddedClosedCaptionTrack(adaptationSetIndices, primaryTrackGroupIndex); } } return trackGroupCount; @@ -728,11 +729,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackGroups.get(trackGroupInfo.embeddedEventMessageTrackGroupIndex); embeddedTrackCount++; } - boolean enableCea608Tracks = trackGroupInfo.embeddedCea608TrackGroupIndex != C.INDEX_UNSET; - TrackGroup embeddedCea608TrackGroup = null; - if (enableCea608Tracks) { - embeddedCea608TrackGroup = trackGroups.get(trackGroupInfo.embeddedCea608TrackGroupIndex); - embeddedTrackCount += embeddedCea608TrackGroup.length; + boolean enableClosedCaptionTrack = + trackGroupInfo.embeddedClosedCaptionTrackGroupIndex != C.INDEX_UNSET; + TrackGroup embeddedClosedCaptionTrackGroup = null; + if (enableClosedCaptionTrack) { + embeddedClosedCaptionTrackGroup = + trackGroups.get(trackGroupInfo.embeddedClosedCaptionTrackGroupIndex); + embeddedTrackCount += embeddedClosedCaptionTrackGroup.length; } Format[] embeddedTrackFormats = new Format[embeddedTrackCount]; @@ -743,12 +746,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_METADATA; embeddedTrackCount++; } - List embeddedCea608TrackFormats = new ArrayList<>(); - if (enableCea608Tracks) { - for (int i = 0; i < embeddedCea608TrackGroup.length; i++) { - embeddedTrackFormats[embeddedTrackCount] = embeddedCea608TrackGroup.getFormat(i); + List embeddedClosedCaptionTrackFormats = new ArrayList<>(); + if (enableClosedCaptionTrack) { + for (int i = 0; i < embeddedClosedCaptionTrackGroup.length; i++) { + embeddedTrackFormats[embeddedTrackCount] = embeddedClosedCaptionTrackGroup.getFormat(i); embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_TEXT; - embeddedCea608TrackFormats.add(embeddedTrackFormats[embeddedTrackCount]); + embeddedClosedCaptionTrackFormats.add(embeddedTrackFormats[embeddedTrackCount]); embeddedTrackCount++; } } @@ -767,7 +770,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackGroupInfo.trackType, elapsedRealtimeOffsetMs, enableEventMessageTrack, - embeddedCea608TrackFormats, + embeddedClosedCaptionTrackFormats, trackPlayerEmsgHandler, transferListener); ChunkSampleStream stream = @@ -824,7 +827,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return false; } - private static Format[] getCea608TrackFormats( + private static Format[] getClosedCaptionTrackFormats( List adaptationSets, int[] adaptationSetIndices) { for (int i : adaptationSetIndices) { AdaptationSet adaptationSet = adaptationSets.get(i); @@ -916,21 +919,21 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public final int eventStreamGroupIndex; public final int primaryTrackGroupIndex; public final int embeddedEventMessageTrackGroupIndex; - public final int embeddedCea608TrackGroupIndex; + public final int embeddedClosedCaptionTrackGroupIndex; public static TrackGroupInfo primaryTrack( int trackType, int[] adaptationSetIndices, int primaryTrackGroupIndex, int embeddedEventMessageTrackGroupIndex, - int embeddedCea608TrackGroupIndex) { + int embeddedClosedCaptionTrackGroupIndex) { return new TrackGroupInfo( trackType, CATEGORY_PRIMARY, adaptationSetIndices, primaryTrackGroupIndex, embeddedEventMessageTrackGroupIndex, - embeddedCea608TrackGroupIndex, + embeddedClosedCaptionTrackGroupIndex, /* eventStreamGroupIndex= */ -1); } @@ -946,8 +949,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /* eventStreamGroupIndex= */ -1); } - public static TrackGroupInfo embeddedCea608Track(int[] adaptationSetIndices, - int primaryTrackGroupIndex) { + public static TrackGroupInfo embeddedClosedCaptionTrack( + int[] adaptationSetIndices, int primaryTrackGroupIndex) { return new TrackGroupInfo( C.TRACK_TYPE_TEXT, CATEGORY_EMBEDDED, @@ -975,14 +978,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int[] adaptationSetIndices, int primaryTrackGroupIndex, int embeddedEventMessageTrackGroupIndex, - int embeddedCea608TrackGroupIndex, + int embeddedClosedCaptionTrackGroupIndex, int eventStreamGroupIndex) { this.trackType = trackType; this.adaptationSetIndices = adaptationSetIndices; this.trackGroupCategory = trackGroupCategory; this.primaryTrackGroupIndex = primaryTrackGroupIndex; this.embeddedEventMessageTrackGroupIndex = embeddedEventMessageTrackGroupIndex; - this.embeddedCea608TrackGroupIndex = embeddedCea608TrackGroupIndex; + this.embeddedClosedCaptionTrackGroupIndex = embeddedClosedCaptionTrackGroupIndex; this.eventStreamGroupIndex = eventStreamGroupIndex; } } From 786edf8ecd63272b9111a8c445b49745ce723d07 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 18 May 2020 13:39:03 +0100 Subject: [PATCH 0263/1052] Tweak DefaultDrmSession acquire & release event behaviour The new behaviour: - If the session is successfully opened, an acquire event is dispatched to all attached dispatchers. - If acquire() is called but the session is already open, the acquire event is dispatched only to the dispatcher provided in to acquire() - If the session is successfully released, a release event is dispatched to all attached dispatchers (in theory at most one should ever be attached at this point). - If release() is called but the session isn't released (because referenceCount > 0) then a release event is dispatched only to the dispatcher provided to release(). PiperOrigin-RevId: 312062422 --- .../exoplayer2/drm/DefaultDrmSession.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index 8f46d5945c..7a304da988 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -273,13 +273,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (openInternal(true)) { doLicense(true); } - } else { + } else if (eventDispatcher != null && isOpen()) { + // If the session is already open then send the acquire event only to the provided dispatcher. // TODO: Add a parameter to onDrmSessionAcquired to indicate whether the session is being // re-used or not. - if (eventDispatcher != null) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); - } + eventDispatcher.dispatch( + DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); } } @@ -302,9 +301,15 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sessionId = null; } releaseCallback.onSessionReleased(this); + dispatchEvent(DrmSessionEventListener::onDrmSessionReleased); } - dispatchEvent(DrmSessionEventListener::onDrmSessionReleased); if (eventDispatcher != null) { + if (isOpen()) { + // If the session is still open then send the release event only to the provided dispatcher + // before removing it. + eventDispatcher.dispatch( + DrmSessionEventListener::onDrmSessionReleased, DrmSessionEventListener.class); + } eventDispatchers.remove(eventDispatcher); } } From ef615754dbc2f4f381b5cf076a50b72e2fb3413d Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 14:05:47 +0100 Subject: [PATCH 0264/1052] Fix handling of fetch errors for post-rolls The ad break time in seconds from IMA was "-1" for postrolls, but this didn't match C.TIME_END_OF_SOURCE in the ad group times array. Handle an ad break time of -1 directly by mapping it onto the last ad group, instead of trying to look it up in the array. PiperOrigin-RevId: 312064886 --- extensions/ima/build.gradle | 1 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 6 +++-- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 23 +++++++++++++++++++ .../google/android/exoplayer2/util/Util.java | 18 +++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index dffb2a00bd..f25a26b3a9 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -50,6 +50,7 @@ dependencies { androidTestImplementation 'com.android.support:multidex:1.0.3' androidTestCompileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') + testImplementation 'com.google.guava:guava:' + guavaVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index fc75749724..ffccaa08d9 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1114,8 +1114,10 @@ public final class ImaAdsLoader } int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); int adGroupIndex = - Arrays.binarySearch( - adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); + adGroupTimeSeconds == -1 + ? adPlaybackState.adGroupCount - 1 + : Util.linearSearch( + adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 97e467fafd..5e7508dc7e 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -56,6 +56,7 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; @@ -105,6 +106,7 @@ public final class ImaAdsLoaderTest { @Mock private ImaFactory mockImaFactory; @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; + @Mock private AdEvent mockPostrollFetchErrorAdEvent; private ViewGroup adViewGroup; private View adOverlayView; @@ -252,6 +254,23 @@ public final class ImaAdsLoaderTest { .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withPostrollFetchError_marksAdAsInErrorState() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, new Float[] {-1f}); + + // Simulate loading an empty postroll ad. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.onAdEvent(mockPostrollFetchErrorAdEvent); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + @Test public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { // Simulate an ad at 2 seconds. @@ -378,6 +397,10 @@ public final class ImaAdsLoaderTest { when(mockAdPodInfo.getAdPosition()).thenReturn(1); when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo); + + when(mockPostrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockPostrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "-1")); } private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index e7b0c5cb7d..75064623f6 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -767,6 +767,24 @@ public final class Util { return C.INDEX_UNSET; } + /** + * Returns the index of the first occurrence of {@code value} in {@code array}, or {@link + * C#INDEX_UNSET} if {@code value} is not contained in {@code array}. + * + * @param array The array to search. + * @param value The value to search for. + * @return The index of the first occurrence of value in {@code array}, or {@link C#INDEX_UNSET} + * if {@code value} is not contained in {@code array}. + */ + public static int linearSearch(long[] array, long value) { + for (int i = 0; i < array.length; i++) { + if (array[i] == value) { + return i; + } + } + return C.INDEX_UNSET; + } + /** * Returns the index of the largest element in {@code array} that is less than (or optionally * equal to) a specified {@code value}. From 5b0e971f0e4e092e5c7a0c5ebd2020625010eac0 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 18 May 2020 14:07:07 +0100 Subject: [PATCH 0265/1052] Make SingleSampleMediaSource provide a media item This is part of go/exoplayer-playlist-retrieval which aims for all MediaSources to associate the media item to the corresponding window. Media items need to be added to the timeline which then assigns it to the window.mediaItem attribute. PiperOrigin-RevId: 312065023 --- .../source/DefaultMediaSourceFactory.java | 10 +- .../source/SingleSampleMediaSource.java | 147 +++++++++--------- 2 files changed, 74 insertions(+), 83 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index 28ef30fa60..a164a1348d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -22,7 +22,6 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; -import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSessionManager; @@ -299,16 +298,9 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { SingleSampleMediaSource.Factory singleSampleSourceFactory = new SingleSampleMediaSource.Factory(dataSourceFactory); for (int i = 0; i < subtitles.size(); i++) { - MediaItem.Subtitle subtitle = subtitles.get(i); - Format subtitleFormat = - new Format.Builder() - .setSampleMimeType(subtitle.mimeType) - .setLanguage(subtitle.language) - .setSelectionFlags(subtitle.selectionFlags) - .build(); mediaSources[i + 1] = singleSampleSourceFactory.createMediaSource( - subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET); + subtitles.get(i), /* durationUs= */ C.TIME_UNSET); } mediaSource = new MergingMediaSource(mediaSources); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 4365c8fda5..2edb1a2baa 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -15,10 +15,15 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.net.Uri; import android.os.Handler; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; @@ -26,8 +31,8 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; +import java.util.Collections; /** * Loads data at a given {@link Uri} as a single sample belonging to a single {@link MediaPeriod}. @@ -60,6 +65,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private boolean treatLoadErrorsAsEndOfStream; @Nullable private Object tag; + @Nullable private String trackId; /** * Creates a factory for {@link SingleSampleMediaSource}s. @@ -68,7 +74,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * be obtained. */ public Factory(DataSource.Factory dataSourceFactory) { - this.dataSourceFactory = Assertions.checkNotNull(dataSourceFactory); + this.dataSourceFactory = checkNotNull(dataSourceFactory); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); } @@ -78,13 +84,23 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * * @param tag A tag for the media source. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. */ public Factory setTag(@Nullable Object tag) { this.tag = tag; return this; } + /** + * Sets an optional track id to be used. + * + * @param trackId An optional track id. + * @return This factory, for convenience. + */ + public Factory setTrackId(@Nullable String trackId) { + this.trackId = trackId; + return this; + } + /** * Sets the minimum number of times to retry if a loading error occurs. See {@link * #setLoadErrorHandlingPolicy} for the default value. @@ -95,7 +111,6 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. */ @Deprecated @@ -111,7 +126,6 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. */ public Factory setLoadErrorHandlingPolicy( @Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { @@ -130,7 +144,6 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * streams, treating them as ended instead. If false, load errors will be propagated * normally by {@link SampleStream#maybeThrowError()}. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. */ public Factory setTreatLoadErrorsAsEndOfStream(boolean treatLoadErrorsAsEndOfStream) { this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; @@ -140,40 +153,34 @@ public final class SingleSampleMediaSource extends BaseMediaSource { /** * Returns a new {@link SingleSampleMediaSource} using the current parameters. * - * @param uri The {@link Uri}. - * @param format The {@link Format} of the media stream. + * @param subtitle The {@link MediaItem.Subtitle}. * @param durationUs The duration of the media stream in microseconds. * @return The new {@link SingleSampleMediaSource}. */ - public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { + public SingleSampleMediaSource createMediaSource(MediaItem.Subtitle subtitle, long durationUs) { return new SingleSampleMediaSource( - uri, + trackId, + subtitle, dataSourceFactory, - format, durationUs, loadErrorHandlingPolicy, treatLoadErrorsAsEndOfStream, tag); } - /** - * @deprecated Use {@link #createMediaSource(Uri, Format, long)} and {@link - * #addEventListener(Handler, MediaSourceEventListener)} instead. - */ + /** @deprecated Use {@link #createMediaSource(MediaItem.Subtitle, long)} instead. */ @Deprecated - public SingleSampleMediaSource createMediaSource( - Uri uri, - Format format, - long durationUs, - @Nullable Handler eventHandler, - @Nullable MediaSourceEventListener eventListener) { - SingleSampleMediaSource mediaSource = createMediaSource(uri, format, durationUs); - if (eventHandler != null && eventListener != null) { - mediaSource.addEventListener(eventHandler, eventListener); - } - return mediaSource; + public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { + return new SingleSampleMediaSource( + format.id == null ? trackId : format.id, + new MediaItem.Subtitle( + uri, checkNotNull(format.sampleMimeType), format.language, format.selectionFlags), + dataSourceFactory, + durationUs, + loadErrorHandlingPolicy, + treatLoadErrorsAsEndOfStream, + tag); } - } private final DataSpec dataSpec; @@ -183,18 +190,11 @@ public final class SingleSampleMediaSource extends BaseMediaSource { private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final boolean treatLoadErrorsAsEndOfStream; private final Timeline timeline; - @Nullable private final Object tag; + private final MediaItem mediaItem; @Nullable private TransferListener transferListener; - /** - * @param uri The {@link Uri} of the media stream. - * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will - * be obtained. - * @param format The {@link Format} associated with the output track. - * @param durationUs The duration of the media stream in microseconds. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public SingleSampleMediaSource( @@ -207,15 +207,8 @@ public final class SingleSampleMediaSource extends BaseMediaSource { DefaultLoadErrorHandlingPolicy.DEFAULT_MIN_LOADABLE_RETRY_COUNT); } - /** - * @param uri The {@link Uri} of the media stream. - * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will - * be obtained. - * @param format The {@link Format} associated with the output track. - * @param durationUs The duration of the media stream in microseconds. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ + @SuppressWarnings("deprecation") @Deprecated public SingleSampleMediaSource( Uri uri, @@ -228,28 +221,15 @@ public final class SingleSampleMediaSource extends BaseMediaSource { dataSourceFactory, format, durationUs, - new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), - /* treatLoadErrorsAsEndOfStream= */ false, - /* tag= */ null); + minLoadableRetryCount, + /* eventHandler= */ null, + /* eventListener= */ null, + /* ignored */ C.INDEX_UNSET, + /* treatLoadErrorsAsEndOfStream= */ false); } - /** - * @param uri The {@link Uri} of the media stream. - * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will - * be obtained. - * @param format The {@link Format} associated with the output track. - * @param durationUs The duration of the media stream in microseconds. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @param eventSourceId An identifier that gets passed to {@code eventListener} methods. - * @param treatLoadErrorsAsEndOfStream If true, load errors will not be propagated by sample - * streams, treating them as ended instead. If false, load errors will be propagated normally - * by {@link SampleStream#maybeThrowError()}. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated - @SuppressWarnings("deprecation") public SingleSampleMediaSource( Uri uri, DataSource.Factory dataSourceFactory, @@ -261,9 +241,10 @@ public final class SingleSampleMediaSource extends BaseMediaSource { int eventSourceId, boolean treatLoadErrorsAsEndOfStream) { this( - uri, + /* trackId= */ null, + new MediaItem.Subtitle( + uri, checkNotNull(format.sampleMimeType), format.language, format.selectionFlags), dataSourceFactory, - format, durationUs, new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), treatLoadErrorsAsEndOfStream, @@ -274,20 +255,33 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } private SingleSampleMediaSource( - Uri uri, + @Nullable String trackId, + MediaItem.Subtitle subtitle, DataSource.Factory dataSourceFactory, - Format format, long durationUs, LoadErrorHandlingPolicy loadErrorHandlingPolicy, boolean treatLoadErrorsAsEndOfStream, @Nullable Object tag) { this.dataSourceFactory = dataSourceFactory; - this.format = format; this.durationUs = durationUs; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; - this.tag = tag; - dataSpec = new DataSpec.Builder().setUri(uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); + mediaItem = + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMediaId(subtitle.uri.toString()) + .setSubtitles(Collections.singletonList(subtitle)) + .setTag(tag) + .build(); + format = + new Format.Builder() + .setId(trackId) + .setSampleMimeType(subtitle.mimeType) + .setLanguage(subtitle.language) + .setSelectionFlags(subtitle.selectionFlags) + .build(); + dataSpec = + new DataSpec.Builder().setUri(subtitle.uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); timeline = new SinglePeriodTimeline( durationUs, @@ -295,7 +289,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - tag); + mediaItem); } // MediaSource implementation. @@ -303,7 +297,12 @@ public final class SingleSampleMediaSource extends BaseMediaSource { @Override @Nullable public Object getTag() { - return tag; + return castNonNull(mediaItem.playbackProperties).tag; + } + + // TODO(bachinger) Add @Override annotation once the method is defined by MediaSource. + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -352,7 +351,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { private final int eventSourceId; public EventListenerWrapper(EventListener eventListener, int eventSourceId) { - this.eventListener = Assertions.checkNotNull(eventListener); + this.eventListener = checkNotNull(eventListener); this.eventSourceId = eventSourceId; } From 9c8cd4b5757513f8115cd56e2dda715c1b1b2e71 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 18 May 2020 15:06:27 +0100 Subject: [PATCH 0266/1052] Add DRM data to AnalyticsCollectorTest This requires lots of new DRM plumbing in FakeMedia{Period,Source} and FakeSampleStream. Part of issue:#6765 PiperOrigin-RevId: 312072332 --- .../android/exoplayer2/ExoPlayerTest.java | 29 ++- .../analytics/AnalyticsCollectorTest.java | 136 +++++++++++ .../audio/MediaCodecAudioRendererTest.java | 2 + .../metadata/MetadataRendererTest.java | 2 + .../source/ClippingMediaSourceTest.java | 9 +- .../video/DecoderVideoRendererTest.java | 8 + .../video/MediaCodecVideoRendererTest.java | 13 ++ .../testutil/FakeAdaptiveMediaPeriod.java | 23 +- .../testutil/FakeAdaptiveMediaSource.java | 4 +- .../exoplayer2/testutil/FakeExoMediaDrm.java | 219 ++++++++++++++++++ .../exoplayer2/testutil/FakeMediaPeriod.java | 53 ++++- .../exoplayer2/testutil/FakeMediaSource.java | 31 ++- .../exoplayer2/testutil/FakeRenderer.java | 16 +- .../exoplayer2/testutil/FakeSampleStream.java | 112 +++++++-- 14 files changed, 611 insertions(+), 46 deletions(-) create mode 100644 testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 770416bb4c..ad95fb11c7 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -42,6 +42,7 @@ import com.google.android.exoplayer2.Player.EventListener; import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.analytics.AnalyticsListener; import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; @@ -600,6 +601,7 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); @@ -635,6 +637,7 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); @@ -661,6 +664,7 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); @@ -904,11 +908,16 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { // Defer completing preparation of the period until playback parameters have been set. fakeMediaPeriodHolder[0] = - new FakeMediaPeriod(trackGroupArray, eventDispatcher, /* deferOnPrepared= */ true); + new FakeMediaPeriod( + trackGroupArray, + drmSessionManager, + eventDispatcher, + /* deferOnPrepared= */ true); createPeriodCalledCountDownLatch.countDown(); return fakeMediaPeriodHolder[0]; } @@ -950,11 +959,16 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { // Defer completing preparation of the period until seek has been sent. fakeMediaPeriodHolder[0] = - new FakeMediaPeriod(trackGroupArray, eventDispatcher, /* deferOnPrepared= */ true); + new FakeMediaPeriod( + trackGroupArray, + drmSessionManager, + eventDispatcher, + /* deferOnPrepared= */ true); createPeriodCalledCountDownLatch.countDown(); return fakeMediaPeriodHolder[0]; } @@ -3666,6 +3680,7 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { @@ -6367,6 +6382,7 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { @@ -6442,9 +6458,10 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { + return new FakeMediaPeriod(trackGroupArray, drmSessionManager, eventDispatcher) { private Loader loader = new Loader("oomLoader"); @Override @@ -6456,11 +6473,15 @@ public final class ExoPlayerTest { @Override protected SampleStream createSampleStream( - long positionUs, TrackSelection selection, EventDispatcher eventDispatcher) { + long positionUs, + TrackSelection selection, + DrmSessionManager drmSessionManager, + EventDispatcher eventDispatcher) { // Create 3 samples without end of stream signal to test that all 3 samples are // still played before the exception is thrown. return new FakeSampleStream( selection.getSelectedFormat(), + drmSessionManager, eventDispatcher, positionUs, /* timeUsIncrement= */ 0, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 4d4d25e486..891d7f28bb 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -31,6 +31,12 @@ import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.ExoMediaDrm; +import com.google.android.exoplayer2.drm.MediaDrmCallback; +import com.google.android.exoplayer2.drm.MediaDrmCallbackException; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -43,16 +49,19 @@ import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner; import com.google.android.exoplayer2.testutil.FakeAudioRenderer; +import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.testutil.FakeVideoRenderer; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; @@ -107,6 +116,24 @@ public final class AnalyticsCollectorTest { private static final int EVENT_DRM_SESSION_RELEASED = 38; private static final int EVENT_VIDEO_FRAME_PROCESSING_OFFSET = 39; + private static final UUID DRM_SCHEME_UUID = + UUID.nameUUIDFromBytes(TestUtil.createByteArray(7, 8, 9)); + + public static final DrmInitData DRM_DATA_1 = + new DrmInitData( + new DrmInitData.SchemeData( + DRM_SCHEME_UUID, + ExoPlayerTestRunner.VIDEO_FORMAT.sampleMimeType, + /* data= */ TestUtil.createByteArray(1, 2, 3))); + public static final DrmInitData DRM_DATA_2 = + new DrmInitData( + new DrmInitData.SchemeData( + DRM_SCHEME_UUID, + ExoPlayerTestRunner.VIDEO_FORMAT.sampleMimeType, + /* data= */ TestUtil.createByteArray(4, 5, 6))); + private static final Format VIDEO_FORMAT_DRM_1 = + ExoPlayerTestRunner.VIDEO_FORMAT.buildUpon().setDrmInitData(DRM_DATA_1).build(); + private static final int TIMEOUT_MS = 10000; private static final Timeline SINGLE_PERIOD_TIMELINE = new FakeTimeline(/* windowCount= */ 1); private static final EventWindowAndPeriodId WINDOW_0 = @@ -114,6 +141,12 @@ public final class AnalyticsCollectorTest { private static final EventWindowAndPeriodId WINDOW_1 = new EventWindowAndPeriodId(/* windowIndex= */ 1, /* mediaPeriodId= */ null); + private final DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setMultiSession(true) + .build(new EmptyDrmCallback()); + private EventWindowAndPeriodId period0; private EventWindowAndPeriodId period1; private EventWindowAndPeriodId period0Seq0; @@ -1158,6 +1191,71 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0); } + @Test + public void drmEvents_singlePeriod() throws Exception { + MediaSource mediaSource = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0); + // The release event is lost because it's posted to "ExoPlayerTest thread" after that thread + // has been quit during clean-up. + assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).isEmpty(); + } + + @Test + public void drmEvents_periodWithSameDrmData_keysReused() throws Exception { + MediaSource mediaSource = + new ConcatenatingMediaSource( + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1)); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0); + // The period1 release event is lost because it's posted to "ExoPlayerTest thread" after that + // thread has been quit during clean-up. + assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).containsExactly(period0); + } + + @Test + public void drmEvents_periodWithDifferentDrmData_keysLoadedAgain() throws Exception { + MediaSource mediaSource = + new ConcatenatingMediaSource( + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1), + new FakeMediaSource( + SINGLE_PERIOD_TIMELINE, + drmSessionManager, + VIDEO_FORMAT_DRM_1.buildUpon().setDrmInitData(DRM_DATA_2).build())); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0, period1); + // The period1 release event is lost because it's posted to "ExoPlayerTest thread" after that + // thread has been quit during clean-up. + assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).containsExactly(period0); + } + + @Test + public void drmEvents_errorHandling() throws Exception { + DrmSessionManager failingDrmSessionManager = + new DefaultDrmSessionManager.Builder().build(new FailingDrmCallback()); + MediaSource mediaSource = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, failingDrmSessionManager, VIDEO_FORMAT_DRM_1); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period0); + } + private void populateEventIds(Timeline timeline) { period0 = new EventWindowAndPeriodId( @@ -1544,4 +1642,42 @@ public final class AnalyticsCollectorTest { } } } + + /** + * A {@link MediaDrmCallback} that returns empty byte arrays for both {@link + * #executeProvisionRequest(UUID, ExoMediaDrm.ProvisionRequest)} and {@link + * #executeKeyRequest(UUID, ExoMediaDrm.KeyRequest)}. + */ + private static final class EmptyDrmCallback implements MediaDrmCallback { + @Override + public byte[] executeProvisionRequest(UUID uuid, ExoMediaDrm.ProvisionRequest request) + throws MediaDrmCallbackException { + return new byte[0]; + } + + @Override + public byte[] executeKeyRequest(UUID uuid, ExoMediaDrm.KeyRequest request) + throws MediaDrmCallbackException { + return new byte[0]; + } + } + + /** + * A {@link MediaDrmCallback} that throws exceptions for both {@link + * #executeProvisionRequest(UUID, ExoMediaDrm.ProvisionRequest)} and {@link + * #executeKeyRequest(UUID, ExoMediaDrm.KeyRequest)}. + */ + private static final class FailingDrmCallback implements MediaDrmCallback { + @Override + public byte[] executeProvisionRequest(UUID uuid, ExoMediaDrm.ProvisionRequest request) + throws MediaDrmCallbackException { + throw new RuntimeException("executeProvision failed"); + } + + @Override + public byte[] executeKeyRequest(UUID uuid, ExoMediaDrm.KeyRequest request) + throws MediaDrmCallbackException { + throw new RuntimeException("executeKey failed"); + } + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java index 48fbdf5564..9741200dfb 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -28,6 +28,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; @@ -111,6 +112,7 @@ public class MediaCodecAudioRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ AUDIO_AAC, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java index 4d1b4f601b..cf57f15f2f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java @@ -22,6 +22,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.metadata.emsg.EventMessage; import com.google.android.exoplayer2.metadata.emsg.EventMessageEncoder; import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; @@ -144,6 +145,7 @@ public class MetadataRendererTest { new Format[] {EMSG_FORMAT}, new FakeSampleStream( EMSG_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 0, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index a2ac9fd54c..c371ec0451 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.Timeline.Window; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.ClippingMediaSource.IllegalClippingException; import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; @@ -566,6 +567,7 @@ public final class ClippingMediaSourceTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { eventDispatcher.downstreamFormatChanged( @@ -578,7 +580,12 @@ public final class ClippingMediaSourceTest { C.usToMs(eventStartUs), C.usToMs(eventEndUs))); return super.createFakeMediaPeriod( - id, trackGroupArray, allocator, eventDispatcher, transferListener); + id, + trackGroupArray, + allocator, + drmSessionManager, + eventDispatcher, + transferListener); } }; final ClippingMediaSource clippingMediaSource = diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java index 17ba57698f..0c1c2787a6 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java @@ -34,6 +34,7 @@ import com.google.android.exoplayer2.RendererConfiguration; import com.google.android.exoplayer2.decoder.DecoderException; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; @@ -185,6 +186,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -213,6 +215,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -240,6 +243,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -270,6 +274,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -278,6 +283,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream2 = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -314,6 +320,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -322,6 +329,7 @@ public final class DecoderVideoRendererTest { FakeSampleStream fakeSampleStream2 = new FakeSampleStream( /* format= */ H264_FORMAT, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java index 4ec7bd8043..85b7604e42 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; @@ -126,6 +127,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50_000, @@ -162,6 +164,7 @@ public class MediaCodecVideoRendererTest { new Format[] {VIDEO_H264}, new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 0, @@ -199,6 +202,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ pAsp1, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 5000, @@ -248,6 +252,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -282,6 +287,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -308,6 +314,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -333,6 +340,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -359,6 +367,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -367,6 +376,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream2 = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -401,6 +411,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -409,6 +420,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream2 = new FakeSampleStream( /* format= */ VIDEO_H264, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, @@ -450,6 +462,7 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ mp4Uhd, + DrmSessionManager.DUMMY, /* eventDispatcher= */ null, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java index 7d74cc2e66..67b08cbd58 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java @@ -67,14 +67,6 @@ public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod this.sequenceableLoader = new CompositeSequenceableLoader(new SequenceableLoader[0]); } - @Override - public void release() { - for (ChunkSampleStream sampleStream : sampleStreams) { - sampleStream.release(); - } - super.release(); - } - @Override public synchronized void prepare(Callback callback, long positionUs) { super.prepare(callback, positionUs); @@ -141,8 +133,11 @@ public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod } @Override - protected SampleStream createSampleStream( - long positionUs, TrackSelection trackSelection, EventDispatcher eventDispatcher) { + protected final SampleStream createSampleStream( + long positionUs, + TrackSelection trackSelection, + DrmSessionManager drmSessionManager, + EventDispatcher eventDispatcher) { FakeChunkSource chunkSource = chunkSourceFactory.createChunkSource(trackSelection, durationUs, transferListener); return new ChunkSampleStream<>( @@ -159,11 +154,19 @@ public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod } @Override + // sampleStream is created by createSampleStream() above. @SuppressWarnings("unchecked") protected void seekSampleStream(SampleStream sampleStream, long positionUs) { ((ChunkSampleStream) sampleStream).seekToUs(positionUs); } + @Override + // sampleStream is created by createSampleStream() above. + @SuppressWarnings("unchecked") + protected void releaseSampleStream(SampleStream sampleStream) { + ((ChunkSampleStream) sampleStream).release(); + } + @Override public void onContinueLoadingRequested(ChunkSampleStream source) { Assertions.checkStateNotNull(callback).onContinueLoadingRequested(this); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java index 0f171dd009..451293746d 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.testutil; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -37,7 +38,7 @@ public class FakeAdaptiveMediaSource extends FakeMediaSource { Timeline timeline, TrackGroupArray trackGroupArray, FakeChunkSource.Factory chunkSourceFactory) { - super(timeline, trackGroupArray); + super(timeline, DrmSessionManager.DUMMY, trackGroupArray); this.chunkSourceFactory = chunkSourceFactory; } @@ -46,6 +47,7 @@ public class FakeAdaptiveMediaSource extends FakeMediaSource { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { Period period = Util.castNonNull(getTimeline()).getPeriodByUid(id.periodUid, new Period()); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java new file mode 100644 index 0000000000..6e4b4f2437 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.testutil; + +import android.media.DeniedByServerException; +import android.media.MediaCryptoException; +import android.media.MediaDrmException; +import android.media.NotProvisionedException; +import android.os.PersistableBundle; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.drm.ExoMediaDrm; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** A fake implementation of {@link ExoMediaDrm} for use in tests. */ +@RequiresApi(18) +public class FakeExoMediaDrm implements ExoMediaDrm { + + private static final KeyRequest DUMMY_KEY_REQUEST = + new KeyRequest(TestUtil.createByteArray(4, 5, 6), "foo.test"); + + private static final ProvisionRequest DUMMY_PROVISION_REQUEST = + new ProvisionRequest(TestUtil.createByteArray(7, 8, 9), "bar.test"); + + private final Map byteProperties; + private final Map stringProperties; + private final Set> openSessionIds; + private final AtomicInteger sessionIdGenerator; + + private int referenceCount; + + /** + * Constructs an instance that returns random and unique {@code sessionIds} for subsequent calls + * to {@link #openSession()}. + */ + public FakeExoMediaDrm() { + byteProperties = new HashMap<>(); + stringProperties = new HashMap<>(); + openSessionIds = new HashSet<>(); + sessionIdGenerator = new AtomicInteger(); + + referenceCount = 1; + } + + @Override + public void setOnEventListener(@Nullable OnEventListener listener) { + // Do nothing. + } + + @Override + public void setOnKeyStatusChangeListener(@Nullable OnKeyStatusChangeListener listener) { + // Do nothing. + } + + @Override + public void setOnExpirationUpdateListener(@Nullable OnExpirationUpdateListener listener) { + // Do nothing. + } + + @Override + public byte[] openSession() throws MediaDrmException { + Assertions.checkState(referenceCount > 0); + byte[] sessionId = + TestUtil.buildTestData(/* length= */ 10, sessionIdGenerator.incrementAndGet()); + if (!openSessionIds.add(toByteList(sessionId))) { + throw new MediaDrmException( + Util.formatInvariant( + "Generated sessionId[%s] clashes with already-open session", + sessionIdGenerator.get())); + } + return sessionId; + } + + @Override + public void closeSession(byte[] sessionId) { + Assertions.checkState(referenceCount > 0); + Assertions.checkState(openSessionIds.remove(toByteList(sessionId))); + } + + @Override + public KeyRequest getKeyRequest( + byte[] scope, + @Nullable List schemeDatas, + int keyType, + @Nullable HashMap optionalParameters) + throws NotProvisionedException { + Assertions.checkState(referenceCount > 0); + return DUMMY_KEY_REQUEST; + } + + @Nullable + @Override + public byte[] provideKeyResponse(byte[] scope, byte[] response) + throws NotProvisionedException, DeniedByServerException { + Assertions.checkState(referenceCount > 0); + return null; + } + + @Override + public ProvisionRequest getProvisionRequest() { + Assertions.checkState(referenceCount > 0); + return DUMMY_PROVISION_REQUEST; + } + + @Override + public void provideProvisionResponse(byte[] response) throws DeniedByServerException { + Assertions.checkState(referenceCount > 0); + } + + @Override + public Map queryKeyStatus(byte[] sessionId) { + Assertions.checkState(referenceCount > 0); + return Collections.emptyMap(); + } + + @Override + public void acquire() { + Assertions.checkState(referenceCount > 0); + referenceCount++; + } + + @Override + public void release() { + referenceCount--; + } + + @Override + public void restoreKeys(byte[] sessionId, byte[] keySetId) { + throw new UnsupportedOperationException(); + } + + @Nullable + @Override + public PersistableBundle getMetrics() { + Assertions.checkState(referenceCount > 0); + + return null; + } + + @Override + public String getPropertyString(String propertyName) { + Assertions.checkState(referenceCount > 0); + @Nullable String value = stringProperties.get(propertyName); + if (value == null) { + throw new IllegalArgumentException("Unrecognized propertyName: " + propertyName); + } + return value; + } + + @Override + public byte[] getPropertyByteArray(String propertyName) { + Assertions.checkState(referenceCount > 0); + @Nullable byte[] value = byteProperties.get(propertyName); + if (value == null) { + throw new IllegalArgumentException("Unrecognized propertyName: " + propertyName); + } + return value; + } + + @Override + public void setPropertyString(String propertyName, String value) { + Assertions.checkState(referenceCount > 0); + stringProperties.put(propertyName, value); + } + + @Override + public void setPropertyByteArray(String propertyName, byte[] value) { + Assertions.checkState(referenceCount > 0); + byteProperties.put(propertyName, value); + } + + @Override + public ExoMediaCrypto createMediaCrypto(byte[] sessionId) throws MediaCryptoException { + Assertions.checkState(referenceCount > 0); + Assertions.checkState(openSessionIds.contains(toByteList(sessionId))); + return new FakeExoMediaCrypto(); + } + + @Nullable + @Override + public Class getExoMediaCryptoType() { + return FakeExoMediaCrypto.class; + } + + private static List toByteList(byte[] byteArray) { + List result = new ArrayList<>(byteArray.length); + for (byte b : byteArray) { + result.add(b); + } + return result; + } + + private static class FakeExoMediaCrypto implements ExoMediaCrypto {} +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index 89e5451e80..35fd2d7f0e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -23,6 +23,7 @@ import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; @@ -49,6 +50,7 @@ public class FakeMediaPeriod implements MediaPeriod { private final TrackGroupArray trackGroupArray; private final List sampleStreams; + private final DrmSessionManager drmSessionManager; private final EventDispatcher eventDispatcher; private final long fakePreparationLoadTaskId; @@ -62,23 +64,46 @@ public class FakeMediaPeriod implements MediaPeriod { private long discontinuityPositionUs; /** + * Constructs a FakeMediaPeriod. + * * @param trackGroupArray The track group array. * @param eventDispatcher A dispatcher for media source events. */ public FakeMediaPeriod(TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher) { - this(trackGroupArray, eventDispatcher, /* deferOnPrepared */ false); + this(trackGroupArray, DrmSessionManager.DUMMY, eventDispatcher, /* deferOnPrepared */ false); } /** + * Constructs a FakeMediaPeriod. + * * @param trackGroupArray The track group array. + * @param drmSessionManager The {@link DrmSessionManager} used for DRM interactions. + * @param eventDispatcher A dispatcher for media source events. + */ + public FakeMediaPeriod( + TrackGroupArray trackGroupArray, + DrmSessionManager drmSessionManager, + EventDispatcher eventDispatcher) { + this(trackGroupArray, drmSessionManager, eventDispatcher, /* deferOnPrepared */ false); + } + + /** + * Constructs a FakeMediaPeriod. + * + * @param trackGroupArray The track group array. + * @param drmSessionManager The DrmSessionManager used for DRM interactions. * @param eventDispatcher A dispatcher for media source events. * @param deferOnPrepared Whether {@link MediaPeriod.Callback#onPrepared(MediaPeriod)} should be * called only after {@link #setPreparationComplete()} has been called. If {@code false} * preparation completes immediately. */ public FakeMediaPeriod( - TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher, boolean deferOnPrepared) { + TrackGroupArray trackGroupArray, + DrmSessionManager drmSessionManager, + EventDispatcher eventDispatcher, + boolean deferOnPrepared) { this.trackGroupArray = trackGroupArray; + this.drmSessionManager = drmSessionManager; this.eventDispatcher = eventDispatcher; this.deferOnPrepared = deferOnPrepared; discontinuityPositionUs = C.TIME_UNSET; @@ -118,6 +143,9 @@ public class FakeMediaPeriod implements MediaPeriod { public void release() { prepared = false; + for (int i = 0; i < sampleStreams.size(); i++) { + releaseSampleStream(sampleStreams.get(i)); + } eventDispatcher.mediaPeriodReleased(); } @@ -173,7 +201,7 @@ public class FakeMediaPeriod implements MediaPeriod { int indexInTrackGroup = selection.getIndexInTrackGroup(selection.getSelectedIndex()); assertThat(indexInTrackGroup).isAtLeast(0); assertThat(indexInTrackGroup).isLessThan(trackGroup.length); - streams[i] = createSampleStream(positionUs, selection, eventDispatcher); + streams[i] = createSampleStream(positionUs, selection, drmSessionManager, eventDispatcher); sampleStreams.add(streams[i]); streamResetFlags[i] = true; } @@ -245,13 +273,18 @@ public class FakeMediaPeriod implements MediaPeriod { * * @param positionUs The position at which the tracks were selected, in microseconds. * @param selection A selection of tracks. + * @param drmSessionManager The DRM session manager. * @param eventDispatcher A dispatcher for events that should be used by the sample stream. * @return A {@link SampleStream} for this selection. */ protected SampleStream createSampleStream( - long positionUs, TrackSelection selection, EventDispatcher eventDispatcher) { + long positionUs, + TrackSelection selection, + DrmSessionManager drmSessionManager, + EventDispatcher eventDispatcher) { return new FakeSampleStream( selection.getSelectedFormat(), + drmSessionManager, eventDispatcher, positionUs, /* timeUsIncrement= */ 0, @@ -262,7 +295,7 @@ public class FakeMediaPeriod implements MediaPeriod { * Seeks inside the given sample stream. * * @param sampleStream A sample stream that was created by a call to {@link - * #createSampleStream(long, TrackSelection, EventDispatcher)}. + * #createSampleStream(long, TrackSelection, DrmSessionManager, EventDispatcher)}. * @param positionUs The position to seek to, in microseconds. */ protected void seekSampleStream(SampleStream sampleStream, long positionUs) { @@ -271,6 +304,16 @@ public class FakeMediaPeriod implements MediaPeriod { .resetSampleStreamItems(positionUs, FakeSampleStream.SINGLE_SAMPLE_THEN_END_OF_STREAM); } + /** + * Releases the given sample stream. + * + * @param sampleStream A sample stream that was created by a call to {@link + * #createSampleStream(long, TrackSelection, DrmSessionManager, EventDispatcher)}. + */ + protected void releaseSampleStream(SampleStream sampleStream) { + ((FakeSampleStream) sampleStream).release(); + } + private void finishPreparation() { prepared = true; Util.castNonNull(prepareCallback).onPrepared(this); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index 1ff8f48c22..cb55127c22 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.ForwardingTimeline; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -73,6 +74,7 @@ public class FakeMediaSource extends BaseMediaSource { private final TrackGroupArray trackGroupArray; private final ArrayList activeMediaPeriods; private final ArrayList createdMediaPeriods; + private final DrmSessionManager drmSessionManager; private @MonotonicNonNull Timeline timeline; private boolean preparedSource; @@ -87,7 +89,19 @@ public class FakeMediaSource extends BaseMediaSource { * can be manually set later using {@link #setNewSourceInfo(Timeline)}. */ public FakeMediaSource(@Nullable Timeline timeline, Format... formats) { - this(timeline, buildTrackGroupArray(formats)); + this(timeline, DrmSessionManager.DUMMY, formats); + } + + /** + * Creates a {@link FakeMediaSource}. This media source creates {@link FakeMediaPeriod}s with a + * {@link TrackGroupArray} using the given {@link Format}s. It passes {@code drmSessionManager} + * into the created periods. The provided {@link Timeline} may be null to prevent an immediate + * source info refresh message when preparing the media source. It can be manually set later using + * {@link #setNewSourceInfo(Timeline)}. + */ + public FakeMediaSource( + @Nullable Timeline timeline, DrmSessionManager drmSessionManager, Format... formats) { + this(timeline, drmSessionManager, buildTrackGroupArray(formats)); } /** @@ -96,13 +110,17 @@ public class FakeMediaSource extends BaseMediaSource { * immediate source info refresh message when preparing the media source. It can be manually set * later using {@link #setNewSourceInfo(Timeline)}. */ - public FakeMediaSource(@Nullable Timeline timeline, TrackGroupArray trackGroupArray) { + public FakeMediaSource( + @Nullable Timeline timeline, + DrmSessionManager drmSessionManager, + TrackGroupArray trackGroupArray) { if (timeline != null) { this.timeline = timeline; } this.trackGroupArray = trackGroupArray; this.activeMediaPeriods = new ArrayList<>(); this.createdMediaPeriods = new ArrayList<>(); + this.drmSessionManager = drmSessionManager; } @Nullable @@ -136,6 +154,7 @@ public class FakeMediaSource extends BaseMediaSource { public synchronized void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { assertThat(preparedSource).isFalse(); transferListener = mediaTransferListener; + drmSessionManager.prepare(); preparedSource = true; releasedSource = false; sourceInfoRefreshHandler = Util.createHandler(); @@ -159,7 +178,8 @@ public class FakeMediaSource extends BaseMediaSource { EventDispatcher eventDispatcher = createEventDispatcher(period.windowIndex, id, period.getPositionInWindowMs()); FakeMediaPeriod mediaPeriod = - createFakeMediaPeriod(id, trackGroupArray, allocator, eventDispatcher, transferListener); + createFakeMediaPeriod( + id, trackGroupArray, allocator, drmSessionManager, eventDispatcher, transferListener); activeMediaPeriods.add(mediaPeriod); createdMediaPeriods.add(id); return mediaPeriod; @@ -179,6 +199,7 @@ public class FakeMediaSource extends BaseMediaSource { assertThat(preparedSource).isTrue(); assertThat(releasedSource).isFalse(); assertThat(activeMediaPeriods.isEmpty()).isTrue(); + drmSessionManager.release(); releasedSource = true; preparedSource = false; Util.castNonNull(sourceInfoRefreshHandler).removeCallbacksAndMessages(null); @@ -242,9 +263,11 @@ public class FakeMediaSource extends BaseMediaSource { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, + DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher); + return new FakeMediaPeriod( + trackGroupArray, drmSessionManager, eventDispatcher, /* deferOnPrepared= */ false); } private void finishSourcePreparation() { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java index b79e211f65..e2e6e2e27a 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.testutil; - +import androidx.annotation.Nullable; import com.google.android.exoplayer2.BaseRenderer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmSession; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; @@ -49,6 +50,8 @@ public class FakeRenderer extends BaseRenderer { private final DecoderInputBuffer buffer; + @Nullable private DrmSession currentDrmSession; + private long playbackPositionUs; private long lastSamplePositionUs; private boolean hasPendingBuffer; @@ -91,7 +94,10 @@ public class FakeRenderer extends BaseRenderer { buffer.clear(); @SampleStream.ReadDataResult int result = readSource(formatHolder, buffer, /* formatRequired= */ false); + if (result == C.RESULT_FORMAT_READ) { + DrmSession.replaceSession(currentDrmSession, formatHolder.drmSession); + currentDrmSession = formatHolder.drmSession; Format format = Assertions.checkNotNull(formatHolder.format); if (MimeTypes.getTrackType(format.sampleMimeType) != getTrackType()) { throw ExoPlaybackException.createForRenderer( @@ -147,6 +153,14 @@ public class FakeRenderer extends BaseRenderer { : RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); } + @Override + protected void onReset() { + if (currentDrmSession != null) { + currentDrmSession.release(/* eventDispatcher= */ null); + currentDrmSession = null; + } + } + /** Called when the renderer reads a new format. */ protected void onFormatChanged(Format format) {} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java index 692cf6e399..0d84bcb48c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java @@ -15,16 +15,24 @@ */ package com.google.android.exoplayer2.testutil; +import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayDeque; import java.util.Arrays; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Fake {@link SampleStream} that outputs a given {@link Format}, any amount of {@link @@ -85,15 +93,16 @@ public class FakeSampleStream implements SampleStream { new FakeSampleStreamItem(new byte[] {0}), FakeSampleStreamItem.END_OF_STREAM_ITEM }; + private final Format initialFormat; private final ArrayDeque fakeSampleStreamItems; private final int timeUsIncrement; - + private final DrmSessionManager drmSessionManager; @Nullable private final EventDispatcher eventDispatcher; - private Format format; + private @MonotonicNonNull Format downstreamFormat; private long timeUs; - private boolean readFormat; private boolean readEOSBuffer; + @Nullable private DrmSession currentDrmSession; /** * Creates fake sample stream which outputs the given {@link Format}, optionally one sample with @@ -107,6 +116,7 @@ public class FakeSampleStream implements SampleStream { Format format, @Nullable EventDispatcher eventDispatcher, boolean shouldOutputSample) { this( format, + DrmSessionManager.DUMMY, eventDispatcher, /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 0, @@ -120,6 +130,7 @@ public class FakeSampleStream implements SampleStream { * FakeSampleStreamItem items}, then end of stream. * * @param format The {@link Format} to output. + * @param drmSessionManager A {@link DrmSessionManager} for DRM interactions. * @param eventDispatcher An {@link EventDispatcher} to notify of read events. * @param firstSampleTimeUs The time at which samples will start being output, in microseconds. * @param timeUsIncrement The time each sample should increase by, in microseconds. @@ -129,11 +140,13 @@ public class FakeSampleStream implements SampleStream { */ public FakeSampleStream( Format format, + DrmSessionManager drmSessionManager, @Nullable EventDispatcher eventDispatcher, long firstSampleTimeUs, int timeUsIncrement, FakeSampleStreamItem... fakeSampleStreamItems) { - this.format = format; + this.initialFormat = format; + this.drmSessionManager = drmSessionManager; this.eventDispatcher = eventDispatcher; this.fakeSampleStreamItems = new ArrayDeque<>(Arrays.asList(fakeSampleStreamItems)); this.timeUs = firstSampleTimeUs; @@ -164,16 +177,21 @@ public class FakeSampleStream implements SampleStream { @Override public boolean isReady() { - return !readFormat || readEOSBuffer || !fakeSampleStreamItems.isEmpty(); + if (fakeSampleStreamItems.isEmpty()) { + return readEOSBuffer || downstreamFormat == null; + } + if (fakeSampleStreamItems.peek().format != null) { + // A format can be read. + return true; + } + return mayReadSample(); } @Override public int readData( FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { - if (!readFormat || formatRequired) { - readFormat = true; - formatHolder.format = format; - notifyEventDispatcher(formatHolder); + if (downstreamFormat == null || formatRequired) { + onFormatResult(downstreamFormat == null ? initialFormat : downstreamFormat, formatHolder); return C.RESULT_FORMAT_READ; } // Once an EOS buffer has been read, send EOS every time. @@ -181,33 +199,79 @@ public class FakeSampleStream implements SampleStream { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; } + if (!fakeSampleStreamItems.isEmpty()) { FakeSampleStreamItem fakeSampleStreamItem = fakeSampleStreamItems.remove(); if (fakeSampleStreamItem.format != null) { - format = fakeSampleStreamItem.format; - formatHolder.format = format; - notifyEventDispatcher(formatHolder); + onFormatResult(fakeSampleStreamItem.format, formatHolder); return C.RESULT_FORMAT_READ; - } - if (fakeSampleStreamItem.sampleData != null) { - byte[] sampleData = fakeSampleStreamItem.sampleData; + } else { + byte[] sampleData = Assertions.checkNotNull(fakeSampleStreamItem.sampleData); + if (fakeSampleStreamItem.flags != 0) { + buffer.setFlags(fakeSampleStreamItem.flags); + if (buffer.isEndOfStream()) { + readEOSBuffer = true; + return C.RESULT_BUFFER_READ; + } + } + if (!mayReadSample()) { + // Put the item back so we can consume it next time. + fakeSampleStreamItems.addFirst(fakeSampleStreamItem); + return C.RESULT_NOTHING_READ; + } buffer.timeUs = timeUs; timeUs += timeUsIncrement; buffer.ensureSpaceForWrite(sampleData.length); buffer.data.put(sampleData); - if (fakeSampleStreamItem.flags != 0) { - buffer.setFlags(fakeSampleStreamItem.flags); - readEOSBuffer = buffer.isEndOfStream(); - } return C.RESULT_BUFFER_READ; } } return C.RESULT_NOTHING_READ; } + private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { + outputFormatHolder.format = newFormat; + @Nullable + DrmInitData oldDrmInitData = downstreamFormat == null ? null : downstreamFormat.drmInitData; + boolean isFirstFormat = downstreamFormat == null; + downstreamFormat = newFormat; + @Nullable DrmInitData newDrmInitData = newFormat.drmInitData; + outputFormatHolder.drmSession = currentDrmSession; + notifyEventDispatcher(outputFormatHolder); + if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { + // Nothing to do. + return; + } + // Ensure we acquire the new session before releasing the previous one in case the same session + // is being used for both DrmInitData. + @Nullable DrmSession previousSession = currentDrmSession; + Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); + currentDrmSession = + newDrmInitData != null + ? drmSessionManager.acquireSession(playbackLooper, eventDispatcher, newDrmInitData) + : drmSessionManager.acquirePlaceholderSession( + playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); + outputFormatHolder.drmSession = currentDrmSession; + + if (previousSession != null) { + previousSession.release(eventDispatcher); + } + } + + private boolean mayReadSample() { + @Nullable DrmSession drmSession = this.currentDrmSession; + return drmSession == null + || drmSession.getState() == DrmSession.STATE_OPENED_WITH_KEYS + || (!fakeSampleStreamItems.isEmpty() + && (fakeSampleStreamItems.peek().flags & C.BUFFER_FLAG_ENCRYPTED) == 0 + && drmSession.playClearSamplesWithoutKeys()); + } + @Override public void maybeThrowError() throws IOException { - // Do nothing. + if (currentDrmSession != null && currentDrmSession.getState() == DrmSession.STATE_ERROR) { + throw Assertions.checkNotNull(currentDrmSession.getError()); + } } @Override @@ -215,6 +279,14 @@ public class FakeSampleStream implements SampleStream { return 0; } + /** Release this SampleStream and all underlying resources. */ + public void release() { + if (currentDrmSession != null) { + currentDrmSession.release(eventDispatcher); + currentDrmSession = null; + } + } + private void notifyEventDispatcher(FormatHolder formatHolder) { if (eventDispatcher != null) { eventDispatcher.downstreamFormatChanged( From ea96ef828c8f365abe1652ce077c5d39df93900c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 16:10:17 +0100 Subject: [PATCH 0267/1052] Add release notes for issues fixed by preloading migration PiperOrigin-RevId: 312080838 --- RELEASENOTES.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d99597535c..054c831fce 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -193,13 +193,25 @@ ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. -* IMA extension: Upgrade to IMA SDK version 3.18.2, and migrate to new - preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). * IMA extension: * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs - ([#6429](https://github.com/google/ExoPlayer/issues/6429)). + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes + several issues involving preloading and handling of ad loading error + cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), + [#5006](https://github.com/google/ExoPlayer/issues/5006), + [#6030](https://github.com/google/ExoPlayer/issues/6030), + [#6097](https://github.com/google/ExoPlayer/issues/6097), + [#6425](https://github.com/google/ExoPlayer/issues/6425), + [#6967](https://github.com/google/ExoPlayer/issues/6967), + [#7041](https://github.com/google/ExoPlayer/issues/7041), + [#7161](https://github.com/google/ExoPlayer/issues/7161), + [#7212](https://github.com/google/ExoPlayer/issues/7212), + [#7340](https://github.com/google/ExoPlayer/issues/7340)). * Add support for timing out ad preloading, to avoid playback getting - stuck if an ad group unexpectedly fails to load. + stuck if an ad group unexpectedly fails to load + ([#5444](https://github.com/google/ExoPlayer/issues/5444), + [#5966](https://github.com/google/ExoPlayer/issues/5966) + [#7002](https://github.com/google/ExoPlayer/issues/7002)). * OkHttp extension: Upgrade OkHttp dependency to 3.12.11. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a From 23f58b11e3f810b488b60bcfb4268d2fdb92af97 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 16:30:12 +0100 Subject: [PATCH 0268/1052] Fix typo PiperOrigin-RevId: 312083761 --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 054c831fce..df3c2503e0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -210,7 +210,7 @@ * Add support for timing out ad preloading, to avoid playback getting stuck if an ad group unexpectedly fails to load ([#5444](https://github.com/google/ExoPlayer/issues/5444), - [#5966](https://github.com/google/ExoPlayer/issues/5966) + [#5966](https://github.com/google/ExoPlayer/issues/5966), [#7002](https://github.com/google/ExoPlayer/issues/7002)). * OkHttp extension: Upgrade OkHttp dependency to 3.12.11. * Cronet extension: Default to using the Cronet implementation in Google Play From 38fc7d8c0d626efb5480ca3a4b4a265cd98cb630 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 18 May 2020 17:15:18 +0100 Subject: [PATCH 0269/1052] Add WebVTT support for ruby-position CSS property This is currently only parsed if the CSS class is specified directly on the tag (e.g. ) PiperOrigin-RevId: 312091710 --- .../exoplayer2/text/webvtt/CssParser.java | 10 ++++ .../text/webvtt/WebvttCssStyle.java | 19 ++++++- .../text/webvtt/WebvttCueParser.java | 28 +++++++--- .../text/webvtt/WebvttCueParserTest.java | 54 ------------------- .../text/webvtt/WebvttDecoderTest.java | 47 ++++++++++++++++ testdata/src/test/assets/webvtt/with_rubies | 25 +++++++++ 6 files changed, 119 insertions(+), 64 deletions(-) create mode 100644 testdata/src/test/assets/webvtt/with_rubies diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java index d87f88ce75..53bd317408 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.text.webvtt; import android.text.TextUtils; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -39,6 +40,9 @@ import java.util.regex.Pattern; private static final String PROPERTY_COLOR = "color"; private static final String PROPERTY_FONT_FAMILY = "font-family"; private static final String PROPERTY_FONT_WEIGHT = "font-weight"; + private static final String PROPERTY_RUBY_POSITION = "ruby-position"; + private static final String VALUE_OVER = "over"; + private static final String VALUE_UNDER = "under"; private static final String PROPERTY_TEXT_COMBINE_UPRIGHT = "text-combine-upright"; private static final String VALUE_ALL = "all"; private static final String VALUE_DIGITS = "digits"; @@ -186,6 +190,12 @@ import java.util.regex.Pattern; // At this point we have a presumably valid declaration, we need to parse it and fill the style. if (PROPERTY_COLOR.equals(property)) { style.setFontColor(ColorParser.parseCssColor(value)); + } else if (PROPERTY_RUBY_POSITION.equals(property)) { + if (VALUE_OVER.equals(value)) { + style.setRubyPosition(RubySpan.POSITION_OVER); + } else if (VALUE_UNDER.equals(value)) { + style.setRubyPosition(RubySpan.POSITION_UNDER); + } } else if (PROPERTY_TEXT_COMBINE_UPRIGHT.equals(property)) { style.setCombineUpright(VALUE_ALL.equals(value) || value.startsWith(VALUE_DIGITS)); } else if (PROPERTY_TEXT_DECORATION.equals(property)) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java index 41b0ba650f..bcb7e0b87c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java @@ -17,8 +17,10 @@ package com.google.android.exoplayer2.text.webvtt; import android.graphics.Typeface; import android.text.TextUtils; +import androidx.annotation.ColorInt; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -83,7 +85,7 @@ public final class WebvttCssStyle { // Style properties. @Nullable private String fontFamily; - private int fontColor; + @ColorInt private int fontColor; private boolean hasFontColor; @OptionalBoolean private int linethrough; @OptionalBoolean private int underline; @@ -91,6 +93,7 @@ public final class WebvttCssStyle { @OptionalBoolean private int italic; @FontSizeUnit private int fontSizeUnit; private float fontSize; + @RubySpan.Position private int rubyPosition; private boolean combineUpright; // Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed @@ -113,6 +116,7 @@ public final class WebvttCssStyle { bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; + rubyPosition = RubySpan.POSITION_UNKNOWN; combineUpright = false; } @@ -256,8 +260,19 @@ public final class WebvttCssStyle { return fontSize; } - public void setCombineUpright(boolean enabled) { + public WebvttCssStyle setRubyPosition(@RubySpan.Position int rubyPosition) { + this.rubyPosition = rubyPosition; + return this; + } + + @RubySpan.Position + public int getRubyPosition() { + return rubyPosition; + } + + public WebvttCssStyle setCombineUpright(boolean enabled) { this.combineUpright = enabled; + return this; } public boolean getCombineUpright() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index bded70e981..b26598424f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -538,6 +538,9 @@ public final class WebvttCueParser { List scratchStyleMatches) { int start = startTag.position; int end = text.length(); + scratchStyleMatches.clear(); + getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); + switch(startTag.name) { case TAG_BOLD: text.setSpan(new StyleSpan(STYLE_BOLD), start, end, @@ -548,7 +551,7 @@ public final class WebvttCueParser { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_RUBY: - applyRubySpans(nestedElements, text, start); + applyRubySpans(text, start, nestedElements, scratchStyleMatches); break; case TAG_UNDERLINE: text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -563,16 +566,25 @@ public final class WebvttCueParser { default: return; } - scratchStyleMatches.clear(); - getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); - int styleMatchesCount = scratchStyleMatches.size(); - for (int i = 0; i < styleMatchesCount; i++) { + + for (int i = 0; i < scratchStyleMatches.size(); i++) { applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); } } private static void applyRubySpans( - List nestedElements, SpannableStringBuilder text, int startTagPosition) { + SpannableStringBuilder text, + int startTagPosition, + List nestedElements, + List styleMatches) { + @RubySpan.Position int rubyPosition = RubySpan.POSITION_OVER; + for (int i = 0; i < styleMatches.size(); i++) { + WebvttCssStyle style = styleMatches.get(i).style; + if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { + rubyPosition = style.getRubyPosition(); + break; + } + } List sortedNestedElements = new ArrayList<>(nestedElements.size()); sortedNestedElements.addAll(nestedElements); Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC); @@ -589,7 +601,7 @@ public final class WebvttCueParser { CharSequence rubyText = text.subSequence(adjustedRubyTextStart, adjustedRubyTextEnd); text.delete(adjustedRubyTextStart, adjustedRubyTextEnd); text.setSpan( - new RubySpan(rubyText.toString(), RubySpan.POSITION_OVER), + new RubySpan(rubyText.toString(), rubyPosition), lastRubyTextEnd, adjustedRubyTextStart, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -877,7 +889,7 @@ public final class WebvttCueParser { @Override public int compareTo(StyleMatch another) { - return this.score - another.score; + return Integer.compare(this.score, another.score); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java index f500029885..778820b451 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java @@ -21,7 +21,6 @@ import static com.google.common.truth.Truth.assertThat; import android.graphics.Color; import android.text.Spanned; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.text.span.RubySpan; import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -50,59 +49,6 @@ public final class WebvttCueParserTest { assertThat(text).hasNoSpans(); } - @Test - public void parseRubyTag() throws Exception { - Spanned text = - parseCueText("Some base textwith ruby and undecorated text"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("Some base text and undecorated text"); - assertThat(text) - .hasRubySpanBetween("Some ".length(), "Some base text".length()) - .withTextAndPosition("with ruby", RubySpan.POSITION_OVER); - } - - @Test - public void parseSingleRubyTagWithMultipleRts() throws Exception { - Spanned text = parseCueText("A1B2C3"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("ABC"); - assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER); - } - - @Test - public void parseMultipleRubyTagsWithSingleRtEach() throws Exception { - Spanned text = - parseCueText("A1B2C3"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("ABC"); - assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER); - } - - @Test - public void parseRubyTagWithNoTextTag() throws Exception { - Spanned text = parseCueText("Some base text with no ruby text"); - - assertThat(text.toString()).isEqualTo("Some base text with no ruby text"); - assertThat(text).hasNoSpans(); - } - - @Test - public void parseRubyTagWithEmptyTextTag() throws Exception { - Spanned text = parseCueText("Some base text with empty ruby text"); - - assertThat(text.toString()).isEqualTo("Some base text with empty ruby text"); - assertThat(text) - .hasRubySpanBetween("Some ".length(), "Some base text with".length()) - .withTextAndPosition("", RubySpan.POSITION_OVER); - } - @Test public void parseDefaultTextColor() throws Exception { Spanned text = parseCueText("In this sentence this text is red"); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java index a2fcfd2f01..128e7b4692 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java @@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.SubtitleDecoderException; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.common.collect.Iterables; @@ -48,6 +49,7 @@ public class WebvttDecoderTest { private static final String WITH_OVERLAPPING_TIMESTAMPS_FILE = "webvtt/with_overlapping_timestamps"; private static final String WITH_VERTICAL_FILE = "webvtt/with_vertical"; + private static final String WITH_RUBIES_FILE = "webvtt/with_rubies"; private static final String WITH_BAD_CUE_HEADER_FILE = "webvtt/with_bad_cue_header"; private static final String WITH_TAGS_FILE = "webvtt/with_tags"; private static final String WITH_CSS_STYLES = "webvtt/with_css_styles"; @@ -345,6 +347,51 @@ public class WebvttDecoderTest { assertThat(thirdCue.verticalType).isEqualTo(Cue.TYPE_UNSET); } + @Test + public void decodeWithRubies() throws Exception { + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_RUBIES_FILE); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(8); + + // Check that an explicit `over` position is read from CSS. + Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))); + assertThat(firstCue.text.toString()).isEqualTo("Some text with over-ruby."); + assertThat((Spanned) firstCue.text) + .hasRubySpanBetween("Some ".length(), "Some text with over-ruby".length()) + .withTextAndPosition("over", RubySpan.POSITION_OVER); + + // Check that `under` is read from CSS and unspecified defaults to `over`. + Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2))); + assertThat(secondCue.text.toString()) + .isEqualTo("Some text with under-ruby and over-ruby (default)."); + assertThat((Spanned) secondCue.text) + .hasRubySpanBetween("Some ".length(), "Some text with under-ruby".length()) + .withTextAndPosition("under", RubySpan.POSITION_UNDER); + assertThat((Spanned) secondCue.text) + .hasRubySpanBetween( + "Some text with under-ruby and ".length(), + "Some text with under-ruby and over-ruby (default)".length()) + .withTextAndPosition("over", RubySpan.POSITION_OVER); + + // Check many tags nested in a single span. + Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); + assertThat(thirdCue.text.toString()).isEqualTo("base1base2base3."); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween(/* start= */ 0, "base1".length()) + .withTextAndPosition("text1", RubySpan.POSITION_OVER); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween("base1".length(), "base1base2".length()) + .withTextAndPosition("text2", RubySpan.POSITION_OVER); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween("base1base2".length(), "base1base2base3".length()) + .withTextAndPosition("text3", RubySpan.POSITION_OVER); + + // Check a span with no tags. + Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); + assertThat(fourthCue.text.toString()).isEqualTo("Some text with no ruby text."); + assertThat((Spanned) fourthCue.text).hasNoSpans(); + } + @Test public void decodeWithBadCueHeader() throws Exception { WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_BAD_CUE_HEADER_FILE); diff --git a/testdata/src/test/assets/webvtt/with_rubies b/testdata/src/test/assets/webvtt/with_rubies new file mode 100644 index 0000000000..9b448632fa --- /dev/null +++ b/testdata/src/test/assets/webvtt/with_rubies @@ -0,0 +1,25 @@ +WEBVTT + +STYLE +::cue(.under) { + ruby-position: under; +} + +STYLE +::cue(.over) { + ruby-position: over; +} + +00:00:01.000 --> 00:00:02.000 +Some text with over-rubyover. + +00:00:03.000 --> 00:00:04.000 +Some text with under-rubyunder and over-ruby (default)over. + +NOTE Many individual rubies in a single tag + +00:00:05.000 --> 00:00:06.000 +base1text1base2text2base3text3. + +00:00:07.000 --> 00:00:08.000 +Some text with no ruby text. From 377bf27f47a1526b918d745a7700040a3035bd45 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 18 May 2020 17:41:28 +0100 Subject: [PATCH 0270/1052] Extend WebVTT ruby-position support to include tags PiperOrigin-RevId: 312096467 --- RELEASENOTES.md | 1 + .../text/webvtt/WebvttCueParser.java | 61 ++++++++++++++----- .../text/webvtt/WebvttDecoderTest.java | 8 +-- testdata/src/test/assets/webvtt/with_rubies | 2 +- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 30dc304d25..68ce72a815 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -128,6 +128,7 @@ of which are supported. * Ignore excess characters in CEA-608 lines (max length is 32) ([#7341](https://github.com/google/ExoPlayer/issues/7341)). + * Add support for WebVTT's `ruby-position` CSS property. * DRM: * Add support for attaching DRM sessions to clear content in the demo app. * Remove `DrmSessionManager` references from all renderers. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index b26598424f..f9220c44f7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -538,8 +538,6 @@ public final class WebvttCueParser { List scratchStyleMatches) { int start = startTag.position; int end = text.length(); - scratchStyleMatches.clear(); - getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); switch(startTag.name) { case TAG_BOLD: @@ -551,7 +549,7 @@ public final class WebvttCueParser { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_RUBY: - applyRubySpans(text, start, nestedElements, scratchStyleMatches); + applyRubySpans(text, cueId, startTag, nestedElements, styles); break; case TAG_UNDERLINE: text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -567,6 +565,8 @@ public final class WebvttCueParser { return; } + scratchStyleMatches.clear(); + getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); for (int i = 0; i < scratchStyleMatches.size(); i++) { applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); } @@ -574,27 +574,29 @@ public final class WebvttCueParser { private static void applyRubySpans( SpannableStringBuilder text, - int startTagPosition, + @Nullable String cueId, + StartTag startTag, List nestedElements, - List styleMatches) { - @RubySpan.Position int rubyPosition = RubySpan.POSITION_OVER; - for (int i = 0; i < styleMatches.size(); i++) { - WebvttCssStyle style = styleMatches.get(i).style; - if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { - rubyPosition = style.getRubyPosition(); - break; - } - } + List styles) { + @RubySpan.Position int rubyTagPosition = getRubyPosition(styles, cueId, startTag); List sortedNestedElements = new ArrayList<>(nestedElements.size()); sortedNestedElements.addAll(nestedElements); Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC); int deletedCharCount = 0; - int lastRubyTextEnd = startTagPosition; + int lastRubyTextEnd = startTag.position; for (int i = 0; i < sortedNestedElements.size(); i++) { if (!TAG_RUBY_TEXT.equals(sortedNestedElements.get(i).startTag.name)) { continue; } Element rubyTextElement = sortedNestedElements.get(i); + // Use the element's ruby-position if set, otherwise the element's and otherwise + // default to OVER. + @RubySpan.Position + int rubyPosition = + firstKnownRubyPosition( + getRubyPosition(styles, cueId, rubyTextElement.startTag), + rubyTagPosition, + RubySpan.POSITION_OVER); // Move the rubyText from spannedText into the RubySpan. int adjustedRubyTextStart = rubyTextElement.startTag.position - deletedCharCount; int adjustedRubyTextEnd = rubyTextElement.endPosition - deletedCharCount; @@ -611,6 +613,37 @@ public final class WebvttCueParser { } } + @RubySpan.Position + private static int getRubyPosition( + List styles, @Nullable String cueId, StartTag startTag) { + List styleMatches = new ArrayList<>(); + getApplicableStyles(styles, cueId, startTag, styleMatches); + for (int i = 0; i < styleMatches.size(); i++) { + WebvttCssStyle style = styleMatches.get(i).style; + if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { + return style.getRubyPosition(); + } + } + return RubySpan.POSITION_UNKNOWN; + } + + @RubySpan.Position + private static int firstKnownRubyPosition( + @RubySpan.Position int position1, + @RubySpan.Position int position2, + @RubySpan.Position int position3) { + if (position1 != RubySpan.POSITION_UNKNOWN) { + return position1; + } + if (position2 != RubySpan.POSITION_UNKNOWN) { + return position2; + } + if (position3 != RubySpan.POSITION_UNKNOWN) { + return position3; + } + throw new IllegalArgumentException(); + } + /** * Adds {@link ForegroundColorSpan}s and {@link BackgroundColorSpan}s to {@code text} for entries * in {@code classes} that match WebVTT's tags nested in a single span. + // Check many tags with different positions nested in a single span. Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); assertThat(thirdCue.text.toString()).isEqualTo("base1base2base3."); assertThat((Spanned) thirdCue.text) .hasRubySpanBetween(/* start= */ 0, "base1".length()) - .withTextAndPosition("text1", RubySpan.POSITION_OVER); + .withTextAndPosition("over1", RubySpan.POSITION_OVER); assertThat((Spanned) thirdCue.text) .hasRubySpanBetween("base1".length(), "base1base2".length()) - .withTextAndPosition("text2", RubySpan.POSITION_OVER); + .withTextAndPosition("under2", RubySpan.POSITION_UNDER); assertThat((Spanned) thirdCue.text) .hasRubySpanBetween("base1base2".length(), "base1base2base3".length()) - .withTextAndPosition("text3", RubySpan.POSITION_OVER); + .withTextAndPosition("under3", RubySpan.POSITION_UNDER); // Check a span with no tags. Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); diff --git a/testdata/src/test/assets/webvtt/with_rubies b/testdata/src/test/assets/webvtt/with_rubies index 9b448632fa..9ff34596b7 100644 --- a/testdata/src/test/assets/webvtt/with_rubies +++ b/testdata/src/test/assets/webvtt/with_rubies @@ -19,7 +19,7 @@ Some text with under-rubyunder and over-ruby ( NOTE Many individual rubies in a single tag 00:00:05.000 --> 00:00:06.000 -base1text1base2text2base3text3. +base1over1base2under2base3under3. 00:00:07.000 --> 00:00:08.000 Some text with no ruby text. From cccb9e1ae8805887b44b279d8afc1ca04a623e3f Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 18 May 2020 18:59:39 +0100 Subject: [PATCH 0271/1052] CacheUtil: Remove confusing remove(DataSpec) method - The remove(DataSpec) method was confusing because it ignored the DataSpec position and range, and instead removed all data with a matching cache key. - The remove(String) method seems better put directly on the Cache interface. PiperOrigin-RevId: 312113302 --- .../offline/ProgressiveDownloader.java | 2 +- .../exoplayer2/offline/SegmentDownloader.java | 4 +-- .../exoplayer2/upstream/cache/Cache.java | 11 ++++-- .../exoplayer2/upstream/cache/CacheUtil.java | 36 ------------------- .../cache/LeastRecentlyUsedCacheEvictor.java | 7 +--- .../upstream/cache/SimpleCache.java | 8 +++++ .../upstream/cache/CacheDataSourceTest.java | 2 +- .../upstream/cache/CacheUtilTest.java | 31 ---------------- .../upstream/cache/SimpleCacheTest.java | 16 +++++++++ 9 files changed, 38 insertions(+), 79 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 7ad3932ef8..794b537ab6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -134,7 +134,7 @@ public final class ProgressiveDownloader implements Downloader { @Override public void remove() { - CacheUtil.remove(dataSpec, dataSource.getCache(), dataSource.getCacheKeyFactory()); + dataSource.getCache().removeSpans(dataSource.getCacheKeyFactory().buildCacheKey(dataSpec)); } private static final class ProgressForwarder implements CacheUtil.ProgressListener { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 544eda85d7..3269d062ee 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -205,13 +205,13 @@ public abstract class SegmentDownloader> impleme M manifest = getManifest(dataSource, manifestDataSpec); List segments = getSegments(dataSource, manifest, true); for (int i = 0; i < segments.size(); i++) { - CacheUtil.remove(segments.get(i).dataSpec, cache, cacheKeyFactory); + cache.removeSpans(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); } } catch (IOException e) { // Ignore exceptions when removing. } finally { // Always attempt to remove the manifest. - CacheUtil.remove(manifestDataSpec, cache, cacheKeyFactory); + cache.removeSpans(cacheKeyFactory.buildCacheKey(manifestDataSpec)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index 1d504159e6..b96388b8af 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -229,16 +229,23 @@ public interface Cache { */ void releaseHoleSpan(CacheSpan holeSpan); + /** + * Removes all {@link CacheSpan CacheSpans} with the given key, deleting the underlying files. + * + * @param key The cache key for the data. + */ + @WorkerThread + void removeSpans(String key); + /** * Removes a cached {@link CacheSpan} from the cache, deleting the underlying file. * *

    This method may be slow and shouldn't normally be called on the main thread. * * @param span The {@link CacheSpan} to remove. - * @throws CacheException If an error is encountered. */ @WorkerThread - void removeSpan(CacheSpan span) throws CacheException; + void removeSpan(CacheSpan span); /** * Queries if a range is entirely available in the cache. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index 08622df3ea..47b61780d8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; import java.io.InterruptedIOException; -import java.util.NavigableSet; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -314,41 +313,6 @@ public final class CacheUtil { } } - /** - * Removes all of the data specified by the {@code dataSpec}. - * - *

    This methods blocks until the operation is complete. - * - * @param dataSpec Defines the data to be removed. - * @param cache A {@link Cache} to store the data. - * @param cacheKeyFactory An optional factory for cache keys. - */ - @WorkerThread - public static void remove( - DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) { - remove(cache, buildCacheKey(dataSpec, cacheKeyFactory)); - } - - /** - * Removes all of the data specified by the {@code key}. - * - *

    This methods blocks until the operation is complete. - * - * @param cache A {@link Cache} to store the data. - * @param key The key whose data should be removed. - */ - @WorkerThread - public static void remove(Cache cache, String key) { - NavigableSet cachedSpans = cache.getCachedSpans(key); - for (CacheSpan cachedSpan : cachedSpans) { - try { - cache.removeSpan(cachedSpan); - } catch (Cache.CacheException e) { - // Do nothing. - } - } - } - private static String buildCacheKey( DataSpec dataSpec, @Nullable CacheKeyFactory cacheKeyFactory) { return (cacheKeyFactory != null ? cacheKeyFactory : CacheKeyFactory.DEFAULT) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java index c88e2643d8..fb461813ae 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.upstream.cache; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.util.TreeSet; /** Evicts least recently used cache files first. */ @@ -70,11 +69,7 @@ public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor { private void evictCache(Cache cache, long requiredSpace) { while (currentSize + requiredSpace > maxBytes && !leastRecentlyUsed.isEmpty()) { - try { - cache.removeSpan(leastRecentlyUsed.first()); - } catch (CacheException e) { - // do nothing. - } + cache.removeSpan(leastRecentlyUsed.first()); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 721dac4d4e..5d3f430d6e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -470,6 +470,14 @@ public final class SimpleCache implements Cache { notifyAll(); } + @Override + public synchronized void removeSpans(String key) { + Assertions.checkState(!released); + for (CacheSpan span : getCachedSpans(key)) { + removeSpanInternal(span); + } + } + @Override public synchronized void removeSpan(CacheSpan span) { Assertions.checkState(!released); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index f6ba0a1e63..133c6b3d73 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -432,7 +432,7 @@ public final class CacheDataSourceTest { TestUtil.readExactly(cacheDataSource, 100); // Delete cached data. - CacheUtil.remove(unboundedDataSpec, cache, /* cacheKeyFactory= */ null); + cache.removeSpans(cacheDataSource.getCacheKeyFactory().buildCacheKey(unboundedDataSpec)); assertCacheEmpty(cache); // Read the rest of the data. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java index 9acc9b11c8..c4115cbc28 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.upstream.cache; -import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty; import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; @@ -29,7 +28,6 @@ import com.google.android.exoplayer2.testutil.FakeDataSet; import com.google.android.exoplayer2.testutil.FakeDataSource; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.File; @@ -287,35 +285,6 @@ public final class CacheUtilTest { assertCachedData(cache, fakeDataSet); } - @Test - public void remove() throws Exception { - FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); - FakeDataSource dataSource = new FakeDataSource(fakeDataSet); - - DataSpec dataSpec = - new DataSpec.Builder() - .setUri("test_data") - .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) - .build(); - CacheUtil.cache( - // Set fragmentSize to 10 to make sure there are multiple spans. - new CacheDataSource( - cache, - dataSource, - new FileDataSource(), - new CacheDataSink(cache, /* fragmentSize= */ 10), - /* flags= */ 0, - /* eventListener= */ null), - dataSpec, - /* progressListener= */ null, - /* isCanceled= */ null, - /* enableEOFException= */ true, - /* temporaryBuffer= */ new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES]); - CacheUtil.remove(dataSpec, cache, /* cacheKeyFactory= */ null); - - assertCacheEmpty(cache); - } - private static final class CachingCounters implements CacheUtil.ProgressListener { private long contentLength = C.LENGTH_UNSET; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 14222f144d..08c63443b4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -225,6 +225,22 @@ public class SimpleCacheTest { Util.recursiveDelete(cacheDir2); } + @Test + public void removeSpans_removesSpansWithSameKey() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 10); + addCache(simpleCache, KEY_1, 20, 10); + simpleCache.releaseHoleSpan(holeSpan); + holeSpan = simpleCache.startReadWrite(KEY_2, 20); + addCache(simpleCache, KEY_2, 20, 10); + simpleCache.releaseHoleSpan(holeSpan); + + simpleCache.removeSpans(KEY_1); + assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); + assertThat(simpleCache.getCachedSpans(KEY_2)).hasSize(1); + } + @Test public void encryptedIndex() throws Exception { byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key From ba33f60485ecc8dbd14f30f42d5422402bbaebf8 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Mon, 18 May 2020 19:08:06 +0100 Subject: [PATCH 0272/1052] Deprecate LoadErrorHandlingPolicy methods without loadTaskId Issue: #7309 PiperOrigin-RevId: 312115330 --- RELEASENOTES.md | 5 ++ .../DefaultLoadErrorHandlingPolicy.java | 10 ++-- .../upstream/LoadErrorHandlingPolicy.java | 49 ++++++------------- 3 files changed, 25 insertions(+), 39 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 68ce72a815..f00b7b1793 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -26,6 +26,11 @@ average video frame processing offset. * Add playlist API ([#6161](https://github.com/google/ExoPlayer/issues/6161)). + * Attach an identifier and extra information to load error events passed + to `LoadErrorHandlingPolicy`. `LoadErrorHandlingPolicy` implementations + must migrate to overriding the non-deprecated methods of the interface + in preparation for deprecated methods' removal in a future ExoPlayer + version ([#7309](https://github.com/google/ExoPlayer/issues/7309)). * Add `play` and `pause` methods to `Player`. * Add `Player.getCurrentLiveOffset` to conveniently return the live offset. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java index 435f4bf578..a5e30b5ccc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java @@ -65,8 +65,8 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { * code HTTP 404 or 410. The duration of the blacklisting is {@link #DEFAULT_TRACK_BLACKLIST_MS}. */ @Override - public long getBlacklistDurationMsFor( - int dataType, long loadDurationMs, IOException exception, int errorCount) { + public long getBlacklistDurationMsFor(LoadErrorInfo loadErrorInfo) { + IOException exception = loadErrorInfo.exception; if (exception instanceof InvalidResponseCodeException) { int responseCode = ((InvalidResponseCodeException) exception).responseCode; return responseCode == 404 // HTTP 404 Not Found. @@ -84,13 +84,13 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { * {@code Math.min((errorCount - 1) * 1000, 5000)}. */ @Override - public long getRetryDelayMsFor( - int dataType, long loadDurationMs, IOException exception, int errorCount) { + public long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { + IOException exception = loadErrorInfo.exception; return exception instanceof ParserException || exception instanceof FileNotFoundException || exception instanceof UnexpectedLoaderException ? C.TIME_UNSET - : Math.min((errorCount - 1) * 1000, 5000); + : Math.min((loadErrorInfo.errorCount - 1) * 1000, 5000); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java index 61e3b8309a..9705437419 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java @@ -65,21 +65,12 @@ public interface LoadErrorHandlingPolicy { } } - /** - * Returns the number of milliseconds for which a resource associated to a provided load error - * should be blacklisted, or {@link C#TIME_UNSET} if the resource should not be blacklisted. - * - * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to - * load. - * @param loadDurationMs The duration in milliseconds of the load from the start of the first load - * attempt up to the point at which the error occurred. - * @param exception The load error. - * @param errorCount The number of errors this load has encountered, including this one. - * @return The blacklist duration in milliseconds, or {@link C#TIME_UNSET} if the resource should - * not be blacklisted. - */ - long getBlacklistDurationMsFor( - int dataType, long loadDurationMs, IOException exception, int errorCount); + /** @deprecated Implement {@link #getBlacklistDurationMsFor(LoadErrorInfo)} instead. */ + @Deprecated + default long getBlacklistDurationMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount) { + throw new UnsupportedOperationException(); + } /** * Returns the number of milliseconds for which a resource associated to a provided load error @@ -89,6 +80,7 @@ public interface LoadErrorHandlingPolicy { * @return The blacklist duration in milliseconds, or {@link C#TIME_UNSET} if the resource should * not be blacklisted. */ + @SuppressWarnings("deprecation") default long getBlacklistDurationMsFor(LoadErrorInfo loadErrorInfo) { return getBlacklistDurationMsFor( loadErrorInfo.mediaLoadData.dataType, @@ -97,25 +89,13 @@ public interface LoadErrorHandlingPolicy { loadErrorInfo.errorCount); } - /** - * Returns the number of milliseconds to wait before attempting the load again, or {@link - * C#TIME_UNSET} if the error is fatal and should not be retried. - * - *

    {@link Loader} clients may ignore the retry delay returned by this method in order to wait - * for a specific event before retrying. However, the load is retried if and only if this method - * does not return {@link C#TIME_UNSET}. - * - * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to - * load. - * @param loadDurationMs The duration in milliseconds of the load from the start of the first load - * attempt up to the point at which the error occurred. - * @param exception The load error. - * @param errorCount The number of errors this load has encountered, including this one. - * @return The number of milliseconds to wait before attempting the load again, or {@link - * C#TIME_UNSET} if the error is fatal and should not be retried. - */ - long getRetryDelayMsFor(int dataType, long loadDurationMs, IOException exception, int errorCount); - + /** @deprecated Implement {@link #getRetryDelayMsFor(LoadErrorInfo)} instead. */ + @Deprecated + default long getRetryDelayMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount) { + throw new UnsupportedOperationException(); + } + /** * Returns the number of milliseconds to wait before attempting the load again, or {@link * C#TIME_UNSET} if the error is fatal and should not be retried. @@ -128,6 +108,7 @@ public interface LoadErrorHandlingPolicy { * @return The number of milliseconds to wait before attempting the load again, or {@link * C#TIME_UNSET} if the error is fatal and should not be retried. */ + @SuppressWarnings("deprecation") default long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { return getRetryDelayMsFor( loadErrorInfo.mediaLoadData.dataType, From be098401e9f9b7fac109a35c760fe204e9ac20e2 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 18 May 2020 20:24:53 +0100 Subject: [PATCH 0273/1052] Cache: Improve documentation and terminology PiperOrigin-RevId: 312130813 --- .../android/exoplayer2/upstream/DataSpec.java | 33 +++---- .../offline/ProgressiveDownloader.java | 2 +- .../exoplayer2/offline/SegmentDownloader.java | 4 +- .../exoplayer2/upstream/cache/Cache.java | 91 ++++++++++--------- .../exoplayer2/upstream/cache/CacheSpan.java | 16 ++-- .../upstream/cache/CachedContent.java | 22 +++-- .../upstream/cache/CachedContentIndex.java | 33 ++++--- .../upstream/cache/SimpleCache.java | 2 +- .../upstream/cache/SimpleCacheSpan.java | 20 ++-- .../upstream/cache/CacheDataSourceTest.java | 2 +- .../upstream/cache/SimpleCacheTest.java | 2 +- 11 files changed, 120 insertions(+), 107 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index cdbf3fee7d..395df63529 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java @@ -27,9 +27,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -/** - * Defines a region of data. - */ +/** Defines a region of data in a resource. */ public final class DataSpec { /** @@ -298,22 +296,21 @@ public final class DataSpec { } } - /** The {@link Uri} from which data should be read. */ + /** A {@link Uri} from which data belonging to the resource can be read. */ public final Uri uri; /** - * The offset of the data located at {@link #uri} within an original resource. + * The offset of the data located at {@link #uri} within the resource. * - *

    Equal to 0 unless {@link #uri} provides access to a subset of an original resource. As an - * example, consider a resource that can be requested over the network and is 1000 bytes long. If - * {@link #uri} points to a local file that contains just bytes [200-300], then this field will be - * set to {@code 200}. + *

    Equal to 0 unless {@link #uri} provides access to a subset of the resource. As an example, + * consider a resource that can be requested over the network and is 1000 bytes long. If {@link + * #uri} points to a local file that contains just bytes [200-300], then this field will be set to + * {@code 200}. * *

    This field can be ignored except for in specific circumstances where the absolute position - * in the original resource is required in a {@link DataSource} chain. One example is when a - * {@link DataSource} needs to decrypt the content as it's read. In this case the absolute - * position in the original resource is typically needed to correctly initialize the decryption - * algorithm. + * in the resource is required in a {@link DataSource} chain. One example is when a {@link + * DataSource} needs to decrypt the content as it's read. In this case the absolute position in + * the resource is typically needed to correctly initialize the decryption algorithm. */ public final long uriPositionOffset; @@ -353,11 +350,11 @@ public final class DataSpec { public final Map httpRequestHeaders; /** - * The absolute position of the data in the full stream. + * The absolute position of the data in the resource. * * @deprecated Use {@link #position} except for specific use cases where the absolute position - * within the original resource is required within a {@link DataSource} chain. Where the - * absolute position is required, use {@code uriPositionOffset + position}. + * within the resource is required within a {@link DataSource} chain. Where the absolute + * position is required, use {@code uriPositionOffset + position}. */ @Deprecated public final long absoluteStreamPosition; @@ -370,8 +367,8 @@ public final class DataSpec { public final long length; /** - * A key that uniquely identifies the original stream. Used for cache indexing. May be null if the - * data spec is not intended to be used in conjunction with a cache. + * A key that uniquely identifies the resource. Used for cache indexing. May be null if the data + * spec is not intended to be used in conjunction with a cache. */ @Nullable public final String key; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 794b537ab6..434ca8fd5d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -134,7 +134,7 @@ public final class ProgressiveDownloader implements Downloader { @Override public void remove() { - dataSource.getCache().removeSpans(dataSource.getCacheKeyFactory().buildCacheKey(dataSpec)); + dataSource.getCache().removeResource(dataSource.getCacheKeyFactory().buildCacheKey(dataSpec)); } private static final class ProgressForwarder implements CacheUtil.ProgressListener { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 3269d062ee..3358cc02c8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -205,13 +205,13 @@ public abstract class SegmentDownloader> impleme M manifest = getManifest(dataSource, manifestDataSpec); List segments = getSegments(dataSource, manifest, true); for (int i = 0; i < segments.size(); i++) { - cache.removeSpans(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); + cache.removeResource(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); } } catch (IOException e) { // Ignore exceptions when removing. } finally { // Always attempt to remove the manifest. - cache.removeSpans(cacheKeyFactory.buildCacheKey(manifestDataSpec)); + cache.removeResource(cacheKeyFactory.buildCacheKey(manifestDataSpec)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index b96388b8af..fe7d34850b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -24,7 +24,20 @@ import java.util.NavigableSet; import java.util.Set; /** - * An interface for cache. + * A cache that supports partial caching of resources. + * + *

    Terminology

    + * + *
      + *
    • A resource is a complete piece of logical data, for example a complete media file. + *
    • A cache key uniquely identifies a resource. URIs are often suitable for use as + * cache keys, however this is not always the case. URIs are not suitable when caching + * resources obtained from a service that generates multiple URIs for the same underlying + * resource, for example because the service uses expiring URIs as a form of access control. + *
    • A cache span is a byte range within a resource, which may or may not be cached. A + * cache span that's not cached is called a hole span. A cache span that is cached + * corresponds to a single underlying file in the cache. + *
    */ public interface Cache { @@ -108,51 +121,45 @@ public interface Cache { void release(); /** - * Registers a listener to listen for changes to a given key. + * Registers a listener to listen for changes to a given resource. * *

    No guarantees are made about the thread or threads on which the listener is called, but it * is guaranteed that listener methods will be called in a serial fashion (i.e. one at a time) and * in the same order as events occurred. * - * @param key The key to listen to. + * @param key The cache key of the resource. * @param listener The listener to add. - * @return The current spans for the key. + * @return The current spans for the resource. */ NavigableSet addListener(String key, Listener listener); /** * Unregisters a listener. * - * @param key The key to stop listening to. + * @param key The cache key of the resource. * @param listener The listener to remove. */ void removeListener(String key, Listener listener); /** - * Returns the cached spans for a given cache key. + * Returns the cached spans for a given resource. * - * @param key The key for which spans should be returned. + * @param key The cache key of the resource. * @return The spans for the key. */ NavigableSet getCachedSpans(String key); - /** - * Returns all keys in the cache. - * - * @return All the keys in the cache. - */ + /** Returns the cache keys of all of the resources that are at least partially cached. */ Set getKeys(); /** * Returns the total disk space in bytes used by the cache. - * - * @return The total disk space in bytes. */ long getCacheSpace(); /** - * A caller should invoke this method when they require data from a given position for a given - * key. + * A caller should invoke this method when they require data starting from a given position in a + * given resource. * *

    If there is a cache entry that overlaps the position, then the returned {@link CacheSpan} * defines the file in which the data is stored. {@link CacheSpan#isCached} is true. The caller @@ -168,8 +175,8 @@ public interface Cache { * *

    This method may be slow and shouldn't normally be called on the main thread. * - * @param key The key of the data being requested. - * @param position The position of the data being requested. + * @param key The cache key of the resource. + * @param position The starting position in the resource from which data is required. * @return The {@link CacheSpan}. * @throws InterruptedException If the thread was interrupted. * @throws CacheException If an error is encountered. @@ -183,8 +190,8 @@ public interface Cache { * *

    This method may be slow and shouldn't normally be called on the main thread. * - * @param key The key of the data being requested. - * @param position The position of the data being requested. + * @param key The cache key of the resource. + * @param position The starting position in the resource from which data is required. * @return The {@link CacheSpan}. Or null if the cache entry is locked. * @throws CacheException If an error is encountered. */ @@ -198,8 +205,8 @@ public interface Cache { * *

    This method may be slow and shouldn't normally be called on the main thread. * - * @param key The cache key for the data. - * @param position The starting position of the data. + * @param key The cache key of the resource being written. + * @param position The starting position in the resource from which data will be written. * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. Used * only to ensure that there is enough space in the cache. * @return The file into which data should be written. @@ -230,12 +237,12 @@ public interface Cache { void releaseHoleSpan(CacheSpan holeSpan); /** - * Removes all {@link CacheSpan CacheSpans} with the given key, deleting the underlying files. + * Removes all {@link CacheSpan CacheSpans} for a resource, deleting the underlying files. * - * @param key The cache key for the data. + * @param key The cache key of the resource being removed. */ @WorkerThread - void removeSpans(String key); + void removeResource(String key); /** * Removes a cached {@link CacheSpan} from the cache, deleting the underlying file. @@ -248,34 +255,36 @@ public interface Cache { void removeSpan(CacheSpan span); /** - * Queries if a range is entirely available in the cache. + * Returns whether the specified range of data in a resource is fully cached. * - * @param key The cache key for the data. - * @param position The starting position of the data. + * @param key The cache key of the resource. + * @param position The starting position of the data in the resource. * @param length The length of the data. * @return true if the data is available in the Cache otherwise false; */ boolean isCached(String key, long position, long length); /** - * Returns the length of the cached data block starting from the {@code position} to the block end - * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap - * to the next cached data up to {@code length} bytes) is returned. + * Returns the length of continuously cached data starting from {@code position}, up to a maximum + * of {@code maxLength}, of a resource. If {@code position} isn't cached then {@code -holeLength} + * is returned, where {@code holeLength} is the length of continuously uncached data starting from + * {@code position}, up to a maximum of {@code maxLength}. * - * @param key The cache key for the data. - * @param position The starting position of the data. - * @param length The maximum length of the data to be returned. - * @return The length of the cached or not cached data block length. + * @param key The cache key of the resource. + * @param position The starting position of the data in the resource. + * @param length The maximum length of the data or hole to be returned. + * @return The length of the continuously cached data, or {@code -holeLength} if {@code position} + * isn't cached. */ long getCachedLength(String key, long position, long length); /** - * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link - * CachedContent} is added if there isn't one already with the given key. + * Applies {@code mutations} to the {@link ContentMetadata} for the given resource. A new {@link + * CachedContent} is added if there isn't one already for the resource. * *

    This method may be slow and shouldn't normally be called on the main thread. * - * @param key The cache key for the data. + * @param key The cache key of the resource. * @param mutations Contains mutations to be applied to the metadata. * @throws CacheException If an error is encountered. */ @@ -284,10 +293,10 @@ public interface Cache { throws CacheException; /** - * Returns a {@link ContentMetadata} for the given key. + * Returns a {@link ContentMetadata} for the given resource. * - * @param key The cache key for the data. - * @return A {@link ContentMetadata} for the given key. + * @param key The cache key of the resource. + * @return The {@link ContentMetadata} for the resource. */ ContentMetadata getContentMetadata(String key); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java index bf51a69240..a4dacbe95c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java @@ -24,13 +24,9 @@ import java.io.File; */ public class CacheSpan implements Comparable { - /** - * The cache key that uniquely identifies the original stream. - */ + /** The cache key that uniquely identifies the resource. */ public final String key; - /** - * The position of the {@link CacheSpan} in the original stream. - */ + /** The position of the {@link CacheSpan} in the resource. */ public final long position; /** * The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an open-ended hole. @@ -49,8 +45,8 @@ public class CacheSpan implements Comparable { * Creates a hole CacheSpan which isn't cached, has no last touch timestamp and no file * associated. * - * @param key The cache key that uniquely identifies the original stream. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key that uniquely identifies the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an * open-ended hole. */ @@ -61,8 +57,8 @@ public class CacheSpan implements Comparable { /** * Creates a CacheSpan. * - * @param key The cache key that uniquely identifies the original stream. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key that uniquely identifies the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an * open-ended hole. * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 7abb9b3896..01671accf3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -21,14 +21,14 @@ import com.google.android.exoplayer2.util.Log; import java.io.File; import java.util.TreeSet; -/** Defines the cached content for a single stream. */ +/** Defines the cached content for a single resource. */ /* package */ final class CachedContent { private static final String TAG = "CachedContent"; - /** The cache file id that uniquely identifies the original stream. */ + /** The cache id that uniquely identifies the resource. */ public final int id; - /** The cache key that uniquely identifies the original stream. */ + /** The cache key that uniquely identifies the resource. */ public final String key; /** The cached spans of this content. */ private final TreeSet cachedSpans; @@ -40,8 +40,8 @@ import java.util.TreeSet; /** * Creates a CachedContent. * - * @param id The cache file id. - * @param key The cache stream key. + * @param id The cache id of the resource. + * @param key The cache key of the resource. */ public CachedContent(int id, String key) { this(id, key, DefaultContentMetadata.EMPTY); @@ -106,13 +106,15 @@ import java.util.TreeSet; } /** - * Returns the length of the cached data block starting from the {@code position} to the block end - * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap - * to the next cached data up to {@code length} bytes) is returned. + * Returns the length of continuously cached data starting from {@code position}, up to a maximum + * of {@code maxLength}. If {@code position} isn't cached, then {@code -holeLength} is returned, + * where {@code holeLength} is the length of continuously un-cached data starting from {@code + * position}, up to a maximum of {@code maxLength}. * * @param position The starting position of the data. - * @param length The maximum length of the data to be returned. - * @return the length of the cached or not cached data block length. + * @param length The maximum length of the data or hole to be returned. + * @return The length of continuously cached data, or {@code -holeLength} if {@code position} + * isn't cached. */ public long getCachedBytesLength(long position, long length) { SimpleCacheSpan span = getSpan(position); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java index 43bf691701..62c831ca11 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -48,6 +48,7 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -223,31 +224,35 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Adds the given key to the index if it isn't there already. + * Adds a resource to the index, if it's not there already. * - * @param key The cache key that uniquely identifies the original stream. - * @return A new or existing CachedContent instance with the given key. + * @param key The cache key of the resource. + * @return The new or existing {@link CachedContent} corresponding to the resource. */ public CachedContent getOrAdd(String key) { @Nullable CachedContent cachedContent = keyToContent.get(key); return cachedContent == null ? addNew(key) : cachedContent; } - /** Returns a CachedContent instance with the given key or null if there isn't one. */ + /** + * Returns the {@link CachedContent} for a resource, or {@code null} if the resource is not + * present in the index. + * + * @param key The cache key of the resource. + */ @Nullable public CachedContent get(String key) { return keyToContent.get(key); } /** - * Returns a Collection of all CachedContent instances in the index. The collection is backed by - * the {@code keyToContent} map, so changes to the map are reflected in the collection, and - * vice-versa. If the map is modified while an iteration over the collection is in progress - * (except through the iterator's own remove operation), the results of the iteration are - * undefined. + * Returns a read only collection of all {@link CachedContent CachedContents} in the index. + * + *

    Subsequent changes to the index are reflected in the returned collection. If the index is + * modified whilst iterating over the collection, the result of the iteration is undefined. */ public Collection getAll() { - return keyToContent.values(); + return Collections.unmodifiableCollection(keyToContent.values()); } /** Returns an existing or new id assigned to the given key. */ @@ -261,7 +266,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return idToKey.get(id); } - /** Removes {@link CachedContent} with the given key from index if it's empty and not locked. */ + /** + * Removes a resource if its {@link CachedContent} is both empty and unlocked. + * + * @param key The cache key of the resource. + */ public void maybeRemove(String key) { @Nullable CachedContent cachedContent = keyToContent.get(key); if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) { @@ -282,7 +291,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } - /** Removes empty and not locked {@link CachedContent} instances from index. */ + /** Removes all resources whose {@link CachedContent CachedContents} are empty and unlocked. */ public void removeEmpty() { String[] keys = new String[keyToContent.size()]; keyToContent.keySet().toArray(keys); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 5d3f430d6e..0cb379b241 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -471,7 +471,7 @@ public final class SimpleCache implements Cache { } @Override - public synchronized void removeSpans(String key) { + public synchronized void removeResource(String key) { Assertions.checkState(!released); for (CacheSpan span : getCachedSpans(key)) { removeSpanInternal(span); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java index d8a0671469..3a5279c949 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -23,7 +23,7 @@ import java.io.File; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** This class stores span metadata in filename. */ +/** A {@link CacheSpan} that encodes metadata into the names of the underlying cache files. */ /* package */ final class SimpleCacheSpan extends CacheSpan { /* package */ static final String COMMON_SUFFIX = ".exo"; @@ -42,7 +42,7 @@ import java.util.regex.Pattern; * * @param cacheDir The parent abstract pathname. * @param id The cache file id. - * @param position The position of the stored data in the original stream. + * @param position The position of the stored data in the resource. * @param timestamp The file timestamp. * @return The cache file. */ @@ -53,8 +53,8 @@ import java.util.regex.Pattern; /** * Creates a lookup span. * - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key of the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @return The span. */ public static SimpleCacheSpan createLookup(String key, long position) { @@ -64,8 +64,8 @@ import java.util.regex.Pattern; /** * Creates an open hole span. * - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key of the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @return The span. */ public static SimpleCacheSpan createOpenHole(String key, long position) { @@ -75,8 +75,8 @@ import java.util.regex.Pattern; /** * Creates a closed hole span. * - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key of the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @param length The length of the {@link CacheSpan}. * @return The span. */ @@ -190,8 +190,8 @@ import java.util.regex.Pattern; } /** - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key of the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an * open-ended hole. * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 133c6b3d73..b4c259689e 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -432,7 +432,7 @@ public final class CacheDataSourceTest { TestUtil.readExactly(cacheDataSource, 100); // Delete cached data. - cache.removeSpans(cacheDataSource.getCacheKeyFactory().buildCacheKey(unboundedDataSpec)); + cache.removeResource(cacheDataSource.getCacheKeyFactory().buildCacheKey(unboundedDataSpec)); assertCacheEmpty(cache); // Read the rest of the data. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 08c63443b4..f2406f9922 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -236,7 +236,7 @@ public class SimpleCacheTest { addCache(simpleCache, KEY_2, 20, 10); simpleCache.releaseHoleSpan(holeSpan); - simpleCache.removeSpans(KEY_1); + simpleCache.removeResource(KEY_1); assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); assertThat(simpleCache.getCachedSpans(KEY_2)).hasSize(1); } From 0de9c007afcbc0fb6c753794afc902eb4aaa47b4 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 18 May 2020 20:30:15 +0100 Subject: [PATCH 0274/1052] Fix naming to reflect that CEA-708 is supported too PiperOrigin-RevId: 312131816 --- .../android/exoplayer2/extractor/CeaUtil.java | 4 ++-- .../extractor/mp4/FragmentedMp4Extractor.java | 17 +++++++++-------- .../exoplayer2/extractor/ts/SeiReader.java | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java index 4c3f97975e..525b335f13 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java @@ -33,8 +33,8 @@ public final class CeaUtil { private static final int PROVIDER_CODE_DIRECTV = 0x2F; /** - * Consumes the unescaped content of an SEI NAL unit, writing the content of any CEA-608 messages - * as samples to all of the provided outputs. + * Consumes the unescaped content of an SEI NAL unit, writing the content of any CEA-608/708 + * messages as samples to all of the provided outputs. * * @param presentationTimeUs The presentation time in microseconds for any samples. * @param seiBuffer The unescaped SEI NAL unit data, excluding the NAL unit start code and type. diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index ae543c1642..37df66ba2c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -171,7 +171,7 @@ public class FragmentedMp4Extractor implements Extractor { // Extractor output. private @MonotonicNonNull ExtractorOutput extractorOutput; private TrackOutput[] emsgTrackOutputs; - private TrackOutput[] cea608TrackOutputs; + private TrackOutput[] ceaTrackOutputs; // Whether extractorOutput.seekMap has been called. private boolean haveOutputSeekMap; @@ -576,12 +576,12 @@ public class FragmentedMp4Extractor implements Extractor { eventMessageTrackOutput.format(EMSG_FORMAT); } } - if (cea608TrackOutputs == null) { - cea608TrackOutputs = new TrackOutput[closedCaptionFormats.size()]; - for (int i = 0; i < cea608TrackOutputs.length; i++) { + if (ceaTrackOutputs == null) { + ceaTrackOutputs = new TrackOutput[closedCaptionFormats.size()]; + for (int i = 0; i < ceaTrackOutputs.length; i++) { TrackOutput output = extractorOutput.track(trackBundles.size() + 1 + i, C.TRACK_TYPE_TEXT); output.format(closedCaptionFormats.get(i)); - cea608TrackOutputs[i] = output; + ceaTrackOutputs[i] = output; } } } @@ -1328,8 +1328,9 @@ public class FragmentedMp4Extractor implements Extractor { output.sampleData(nalStartCode, 4); // Write the NAL unit type byte. output.sampleData(nalPrefix, 1); - processSeiNalUnitPayload = cea608TrackOutputs.length > 0 - && NalUnitUtil.isNalUnitSei(track.format.sampleMimeType, nalPrefixData[4]); + processSeiNalUnitPayload = + ceaTrackOutputs.length > 0 + && NalUnitUtil.isNalUnitSei(track.format.sampleMimeType, nalPrefixData[4]); sampleBytesWritten += 5; sampleSize += nalUnitLengthFieldLengthDiff; } else { @@ -1345,7 +1346,7 @@ public class FragmentedMp4Extractor implements Extractor { // If the format is H.265/HEVC the NAL unit header has two bytes so skip one more byte. nalBuffer.setPosition(MimeTypes.VIDEO_H265.equals(track.format.sampleMimeType) ? 1 : 0); nalBuffer.setLimit(unescapedLength); - CeaUtil.consume(sampleTimeUs, nalBuffer, cea608TrackOutputs); + CeaUtil.consume(sampleTimeUs, nalBuffer, ceaTrackOutputs); } else { // Write the payload of the NAL unit. writtenBytes = output.sampleData(input, sampleCurrentNalBytesRemaining, false); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java index 6d8cb0da8c..9fff73315c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java @@ -27,7 +27,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.util.List; -/** Consumes SEI buffers, outputting contained CEA-608 messages to a {@link TrackOutput}. */ +/** Consumes SEI buffers, outputting contained CEA-608/708 messages to a {@link TrackOutput}. */ public final class SeiReader { private final List closedCaptionFormats; From 86b31e29549fbb28f281bb5d52e426c0019d62c1 Mon Sep 17 00:00:00 2001 From: Joris de Groot Date: Tue, 19 May 2020 15:22:43 +0200 Subject: [PATCH 0275/1052] Added storage not low as download requirement Added monitoring storage levels in RequirementsWatcher Added dependency on extension-workmanager to the demo app to be able to test with WorkManagerScheduler Added getSupportedRequirements method to Scheduler interface Implemented getSupportedRequirements for schedulers --- demos/main/build.gradle | 1 + .../jobdispatcher/JobDispatcherScheduler.java | 13 +++++++ .../ext/workmanager/WorkManagerScheduler.java | 19 +++++++++ .../exoplayer2/offline/DownloadService.java | 4 ++ .../scheduler/PlatformScheduler.java | 15 +++++++ .../exoplayer2/scheduler/Requirements.java | 39 ++++++++++++++++++- .../scheduler/RequirementsWatcher.java | 4 ++ .../exoplayer2/scheduler/Scheduler.java | 9 +++++ 8 files changed, 103 insertions(+), 1 deletion(-) diff --git a/demos/main/build.gradle b/demos/main/build.gradle index b7a8666fe3..dd4fec385c 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -70,6 +70,7 @@ dependencies { implementation project(modulePrefix + 'library-hls') implementation project(modulePrefix + 'library-smoothstreaming') implementation project(modulePrefix + 'library-ui') + withExtensionsImplementation project(path: modulePrefix + 'extension-workmanager') withExtensionsImplementation project(path: modulePrefix + 'extension-av1') withExtensionsImplementation project(path: modulePrefix + 'extension-ffmpeg') withExtensionsImplementation project(path: modulePrefix + 'extension-flac') diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java index 8841f8355f..9c72e4b86d 100644 --- a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java +++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java @@ -96,6 +96,19 @@ public final class JobDispatcherScheduler implements Scheduler { return result == FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS; } + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + Requirements supportedRequirements = requirements; + if (requirements.isStorageNotLowRequired()) { + Log.w(TAG, "Storage not low requirement not supported on the JobDispatcherScheduler " + + "Requirement removed."); + int newRequirements = + supportedRequirements.getRequirements() ^ Requirements.DEVICE_STORAGE_NOT_LOW; + supportedRequirements = new Requirements(newRequirements); + } + return supportedRequirements; + } + private static Job buildJob( FirebaseJobDispatcher dispatcher, Requirements requirements, diff --git a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java index 97b132980d..9ec09c6c92 100644 --- a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java +++ b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java @@ -87,6 +87,12 @@ public final class WorkManagerScheduler implements Scheduler { if (requirements.isIdleRequired() && Util.SDK_INT >= 23) { setRequiresDeviceIdle(builder); + } else if (requirements.isIdleRequired()) { + Log.w(TAG, "Is idle requirement is only available on API 23 and up."); + } + + if (requirements.isStorageNotLowRequired()) { + builder.setRequiresStorageNotLow(true); } return builder.build(); @@ -108,6 +114,19 @@ public final class WorkManagerScheduler implements Scheduler { return builder.build(); } + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + Requirements supportedRequirements = requirements; + if (requirements.isIdleRequired() && Util.SDK_INT < 23) { + Log.w(TAG, "Is idle requirement not supported on the WorkManagerScheduler on API below 23. " + + "Requirement removed."); + int newRequirements = + supportedRequirements.getRequirements() ^ Requirements.DEVICE_IDLE; + supportedRequirements = new Requirements(newRequirements); + } + return supportedRequirements; + } + private static OneTimeWorkRequest buildWorkRequest(Constraints constraints, Data inputData) { OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(SchedulerWorker.class); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 0ee9a83260..6f3a9fd4ca 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -658,6 +658,10 @@ public abstract class DownloadService extends Service { if (requirements == null) { Log.e(TAG, "Ignored SET_REQUIREMENTS: Missing " + KEY_REQUIREMENTS + " extra"); } else { + @Nullable Scheduler scheduler = getScheduler(); + if (scheduler != null) { + requirements = scheduler.getSupportedRequirements(requirements); + } downloadManager.setRequirements(requirements); } break; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java index c4861abdf3..e754c75d74 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java @@ -86,6 +86,21 @@ public final class PlatformScheduler implements Scheduler { return true; } + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + Requirements supportedRequirements = requirements; + if (Util.SDK_INT < 26) { + if (requirements.isStorageNotLowRequired()) { + Log.w(TAG, "Storage not low requirement not supported on the PlatformScheduler" + + "on API below 26. Requirement removed."); + int newRequirements = + supportedRequirements.getRequirements() ^ Requirements.DEVICE_STORAGE_NOT_LOW; + supportedRequirements = new Requirements(newRequirements); + } + } + return supportedRequirements; + } + // @RequiresPermission constructor annotation should ensure the permission is present. @SuppressWarnings("MissingPermission") private static JobInfo buildJobInfo( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 8919a26720..ab364a53b8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -45,7 +45,7 @@ public final class Requirements implements Parcelable { @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {NETWORK, NETWORK_UNMETERED, DEVICE_IDLE, DEVICE_CHARGING}) + value = {NETWORK, NETWORK_UNMETERED, DEVICE_IDLE, DEVICE_CHARGING, DEVICE_STORAGE_NOT_LOW}) public @interface RequirementFlags {} /** Requirement that the device has network connectivity. */ @@ -56,6 +56,8 @@ public final class Requirements implements Parcelable { public static final int DEVICE_IDLE = 1 << 2; /** Requirement that the device is charging. */ public static final int DEVICE_CHARGING = 1 << 3; + /** Requirement that the storage is not low. */ + public static final int DEVICE_STORAGE_NOT_LOW = 1 << 4; @RequirementFlags private final int requirements; @@ -94,6 +96,10 @@ public final class Requirements implements Parcelable { return (requirements & DEVICE_IDLE) != 0; } + public boolean isStorageNotLowRequired() { + return (requirements & DEVICE_STORAGE_NOT_LOW) != 0; + } + /** * Returns whether the requirements are met. * @@ -119,6 +125,9 @@ public final class Requirements implements Parcelable { if (isIdleRequired() && !isDeviceIdle(context)) { notMetRequirements |= DEVICE_IDLE; } + if (isStorageNotLowRequired() && !isStorageNotLow(context)) { + notMetRequirements |= DEVICE_STORAGE_NOT_LOW; + } return notMetRequirements; } @@ -162,6 +171,34 @@ public final class Requirements implements Parcelable { : Util.SDK_INT >= 20 ? !powerManager.isInteractive() : !powerManager.isScreenOn(); } + /** + * Implementation taken from the the WorkManager source. + * @see StorageNotLowTracker + */ + private boolean isStorageNotLow(Context context) { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(Intent.ACTION_DEVICE_STORAGE_OK); + intentFilter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW); + Intent intent = context.registerReceiver(null, intentFilter); + if (intent == null || intent.getAction() == null) { + // ACTION_DEVICE_STORAGE_LOW is a sticky broadcast that is removed when sufficient + // storage is available again. ACTION_DEVICE_STORAGE_OK is not sticky. So if we + // don't receive anything here, we can assume that the storage state is okay. + return true; + } else { + switch (intent.getAction()) { + case Intent.ACTION_DEVICE_STORAGE_OK: + return true; + case Intent.ACTION_DEVICE_STORAGE_LOW: + return false; + default: + // This should never happen because the intent filter is configured + // correctly. + return true; + } + } + } + private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { // It's possible to query NetworkCapabilities from API level 23, but RequirementsWatcher only // fires an event to update its Requirements when NetworkCapabilities change from API level 24. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index 797b7f7170..9109242db1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -104,6 +104,10 @@ public final class RequirementsWatcher { filter.addAction(Intent.ACTION_SCREEN_OFF); } } + if (requirements.isStorageNotLowRequired()) { + filter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW); + filter.addAction(Intent.ACTION_DEVICE_STORAGE_OK); + } receiver = new DeviceStatusChangeReceiver(); context.registerReceiver(receiver, filter, null, handler); return notMetRequirements; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java index b5a6f40424..bcbd7420b0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java @@ -45,4 +45,13 @@ public interface Scheduler { * @return Whether cancellation was successful. */ boolean cancel(); + + /** + * Checks if this {@link Scheduler} supports the provided {@link Requirements}. If all + * requirements are supported the same object is returned. If not all requirements are + * supported a new {@code Requirements} object is returned containing the supported requirements. + * @param requirements The requirements to check. + * @return The requirements supported by this scheduler. + */ + Requirements getSupportedRequirements(Requirements requirements); } From 6e02a815018dc2217cb1c4df927c720720705352 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 19 May 2020 11:24:23 +0100 Subject: [PATCH 0276/1052] Cleanup WebvttCueParser and WebvttCssStyle - Remove scratchStyleMatches output parameter from WebvttCueParser. - Switch from String[] to Set for representing classes. - In-line WebvttCssStyle.reset() since it's not used anywhere else. PiperOrigin-RevId: 312249552 --- .../text/webvtt/WebvttCssStyle.java | 22 +++---- .../text/webvtt/WebvttCueParser.java | 63 +++++++++---------- .../exoplayer2/text/webvtt/CssParserTest.java | 53 +++++++++++----- 3 files changed, 74 insertions(+), 64 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java index bcb7e0b87c..9a00151ccb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java @@ -27,8 +27,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Arrays; import java.util.Collections; -import java.util.List; -import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import java.util.HashSet; +import java.util.Set; /** * Style object of a Css style block in a Webvtt file. @@ -80,7 +80,7 @@ public final class WebvttCssStyle { // Selector properties. private String targetId; private String targetTag; - private List targetClasses; + private Set targetClasses; private String targetVoice; // Style properties. @@ -96,18 +96,10 @@ public final class WebvttCssStyle { @RubySpan.Position private int rubyPosition; private boolean combineUpright; - // Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed - // because reset() only assigns fields, it doesn't read any. - @SuppressWarnings("nullness:method.invocation.invalid") public WebvttCssStyle() { - reset(); - } - - @EnsuresNonNull({"targetId", "targetTag", "targetClasses", "targetVoice"}) - public void reset() { targetId = ""; targetTag = ""; - targetClasses = Collections.emptyList(); + targetClasses = Collections.emptySet(); targetVoice = ""; fontFamily = null; hasFontColor = false; @@ -129,7 +121,7 @@ public final class WebvttCssStyle { } public void setTargetClasses(String[] targetClasses) { - this.targetClasses = Arrays.asList(targetClasses); + this.targetClasses = new HashSet<>(Arrays.asList(targetClasses)); } public void setTargetVoice(String targetVoice) { @@ -155,7 +147,7 @@ public final class WebvttCssStyle { * @return The score of the match, zero if there is no match. */ public int getSpecificityScore( - @Nullable String id, @Nullable String tag, String[] classes, @Nullable String voice) { + @Nullable String id, @Nullable String tag, Set classes, @Nullable String voice) { if (targetId.isEmpty() && targetTag.isEmpty() && targetClasses.isEmpty() && targetVoice.isEmpty()) { // The selector is universal. It matches with the minimum score if and only if the given @@ -166,7 +158,7 @@ public final class WebvttCssStyle { score = updateScoreForMatch(score, targetId, id, 0x40000000); score = updateScoreForMatch(score, targetTag, tag, 2); score = updateScoreForMatch(score, targetVoice, voice, 4); - if (score == -1 || !Arrays.asList(classes).containsAll(targetClasses)) { + if (score == -1 || !classes.containsAll(targetClasses)) { return 0; } else { score += targetClasses.size() * 4; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index f9220c44f7..c56fa080a0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -49,8 +49,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -239,7 +241,6 @@ public final class WebvttCueParser { @Nullable String id, String markup, List styles) { SpannableStringBuilder spannedText = new SpannableStringBuilder(); ArrayDeque startTagStack = new ArrayDeque<>(); - List scratchStyleMatches = new ArrayList<>(); int pos = 0; List nestedElements = new ArrayList<>(); while (pos < markup.length()) { @@ -270,8 +271,7 @@ public final class WebvttCueParser { break; } startTag = startTagStack.pop(); - applySpansForTag( - id, startTag, nestedElements, spannedText, styles, scratchStyleMatches); + applySpansForTag(id, startTag, nestedElements, spannedText, styles); if (!startTagStack.isEmpty()) { nestedElements.add(new Element(startTag, spannedText.length())); } else { @@ -307,16 +307,14 @@ public final class WebvttCueParser { } // apply unclosed tags while (!startTagStack.isEmpty()) { - applySpansForTag( - id, startTagStack.pop(), nestedElements, spannedText, styles, scratchStyleMatches); + applySpansForTag(id, startTagStack.pop(), nestedElements, spannedText, styles); } applySpansForTag( id, StartTag.buildWholeCueVirtualTag(), /* nestedElements= */ Collections.emptyList(), spannedText, - styles, - scratchStyleMatches); + styles); return SpannedString.valueOf(spannedText); } @@ -534,8 +532,7 @@ public final class WebvttCueParser { StartTag startTag, List nestedElements, SpannableStringBuilder text, - List styles, - List scratchStyleMatches) { + List styles) { int start = startTag.position; int end = text.length(); @@ -565,10 +562,9 @@ public final class WebvttCueParser { return; } - scratchStyleMatches.clear(); - getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); - for (int i = 0; i < scratchStyleMatches.size(); i++) { - applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); + List applicableStyles = getApplicableStyles(styles, cueId, startTag); + for (int i = 0; i < applicableStyles.size(); i++) { + applyStyleToText(text, applicableStyles.get(i).style, start, end); } } @@ -616,8 +612,7 @@ public final class WebvttCueParser { @RubySpan.Position private static int getRubyPosition( List styles, @Nullable String cueId, StartTag startTag) { - List styleMatches = new ArrayList<>(); - getApplicableStyles(styles, cueId, startTag, styleMatches); + List styleMatches = getApplicableStyles(styles, cueId, startTag); for (int i = 0; i < styleMatches.size(); i++) { WebvttCssStyle style = styleMatches.get(i).style; if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { @@ -652,7 +647,7 @@ public final class WebvttCueParser { * colors. */ private static void applyDefaultColors( - SpannableStringBuilder text, String[] classes, int start, int end) { + SpannableStringBuilder text, Set classes, int start, int end) { for (String className : classes) { if (DEFAULT_TEXT_COLORS.containsKey(className)) { int color = DEFAULT_TEXT_COLORS.get(className); @@ -746,20 +741,18 @@ public final class WebvttCueParser { return Util.splitAtFirst(tagExpression, "[ \\.]")[0]; } - private static void getApplicableStyles( - List declaredStyles, - @Nullable String id, - StartTag tag, - List output) { - int styleCount = declaredStyles.size(); - for (int i = 0; i < styleCount; i++) { + private static List getApplicableStyles( + List declaredStyles, @Nullable String id, StartTag tag) { + List applicableStyles = new ArrayList<>(); + for (int i = 0; i < declaredStyles.size(); i++) { WebvttCssStyle style = declaredStyles.get(i); int score = style.getSpecificityScore(id, tag.name, tag.classes, tag.voice); if (score > 0) { - output.add(new StyleMatch(score, style)); + applicableStyles.add(new StyleMatch(score, style)); } } - Collections.sort(output); + Collections.sort(applicableStyles); + return applicableStyles; } private static final class WebvttCueInfoBuilder { @@ -929,14 +922,12 @@ public final class WebvttCueParser { private static final class StartTag { - private static final String[] NO_CLASSES = new String[0]; - public final String name; public final int position; public final String voice; - public final String[] classes; + public final Set classes; - private StartTag(String name, int position, String voice, String[] classes) { + private StartTag(String name, int position, String voice, Set classes) { this.position = position; this.name = name; this.voice = voice; @@ -956,17 +947,19 @@ public final class WebvttCueParser { } String[] nameAndClasses = Util.split(fullTagExpression, "\\."); String name = nameAndClasses[0]; - String[] classes; - if (nameAndClasses.length > 1) { - classes = Util.nullSafeArrayCopyOfRange(nameAndClasses, 1, nameAndClasses.length); - } else { - classes = NO_CLASSES; + Set classes = new HashSet<>(); + for (int i = 1; i < nameAndClasses.length; i++) { + classes.add(nameAndClasses[i]); } return new StartTag(name, position, voice, classes); } public static StartTag buildWholeCueVirtualTag() { - return new StartTag("", 0, "", new String[0]); + return new StartTag( + /* name= */ "", + /* position= */ 0, + /* voice= */ "", + /* classes= */ Collections.emptySet()); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java index b77d4f14ab..d1c7ba78e9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java @@ -21,6 +21,9 @@ import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import org.junit.Before; import org.junit.Test; @@ -163,32 +166,54 @@ public final class CssParserTest { public void styleScoreSystem() { WebvttCssStyle style = new WebvttCssStyle(); // Universal selector. - assertThat(style.getSpecificityScore("", "", new String[0], "")).isEqualTo(1); + assertThat(style.getSpecificityScore("", "", Collections.emptySet(), "")).isEqualTo(1); // Class match without tag match. style.setTargetClasses(new String[] { "class1", "class2"}); - assertThat(style.getSpecificityScore("", "", new String[]{"class1", "class2", "class3"}, - "")).isEqualTo(8); + assertThat( + style.getSpecificityScore( + "", "", new HashSet<>(Arrays.asList("class1", "class2", "class3")), "")) + .isEqualTo(8); // Class and tag match style.setTargetTagName("b"); - assertThat(style.getSpecificityScore("", "b", - new String[]{"class1", "class2", "class3"}, "")).isEqualTo(10); + assertThat( + style.getSpecificityScore( + "", "b", new HashSet<>(Arrays.asList("class1", "class2", "class3")), "")) + .isEqualTo(10); // Class insufficiency. - assertThat(style.getSpecificityScore("", "b", new String[]{"class1", "class"}, "")) + assertThat( + style.getSpecificityScore("", "b", new HashSet<>(Arrays.asList("class1", "class")), "")) .isEqualTo(0); // Voice, classes and tag match. style.setTargetVoice("Manuel Cráneo"); - assertThat(style.getSpecificityScore("", "b", - new String[]{"class1", "class2", "class3"}, "Manuel Cráneo")).isEqualTo(14); + assertThat( + style.getSpecificityScore( + "", + "b", + new HashSet<>(Arrays.asList("class1", "class2", "class3")), + "Manuel Cráneo")) + .isEqualTo(14); // Voice mismatch. - assertThat(style.getSpecificityScore(null, "b", - new String[]{"class1", "class2", "class3"}, "Manuel Craneo")).isEqualTo(0); + assertThat( + style.getSpecificityScore( + null, + "b", + new HashSet<>(Arrays.asList("class1", "class2", "class3")), + "Manuel Craneo")) + .isEqualTo(0); // Id, voice, classes and tag match. style.setTargetId("id"); - assertThat(style.getSpecificityScore("id", "b", - new String[]{"class1", "class2", "class3"}, "Manuel Cráneo")).isEqualTo(0x40000000 + 14); + assertThat( + style.getSpecificityScore( + "id", + "b", + new HashSet<>(Arrays.asList("class1", "class2", "class3")), + "Manuel Cráneo")) + .isEqualTo(0x40000000 + 14); // Id mismatch. - assertThat(style.getSpecificityScore("id1", "b", - new String[]{"class1", "class2", "class3"}, "")).isEqualTo(0); + assertThat( + style.getSpecificityScore( + "id1", "b", new HashSet<>(Arrays.asList("class1", "class2", "class3")), "")) + .isEqualTo(0); } // Utility methods. From 1be295ab0cae5d40c5212f000c4d0b0ad211bc7e Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 19 May 2020 14:19:56 +0100 Subject: [PATCH 0277/1052] Fix SimpleCache.getCachedLength rollover bug & improve test coverage PiperOrigin-RevId: 312266156 --- .../upstream/cache/CachedContent.java | 12 +- .../upstream/cache/SimpleCacheTest.java | 111 +++++++++++++----- 2 files changed, 93 insertions(+), 30 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 01671accf3..82dfd3fa99 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import androidx.annotation.Nullable; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import java.io.File; import java.util.TreeSet; @@ -117,12 +119,18 @@ import java.util.TreeSet; * isn't cached. */ public long getCachedBytesLength(long position, long length) { + checkArgument(position >= 0); + checkArgument(length >= 0); SimpleCacheSpan span = getSpan(position); if (span.isHoleSpan()) { // We don't have a span covering the start of the queried region. return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length); } long queryEndPosition = position + length; + if (queryEndPosition < 0) { + // The calculation rolled over (length is probably Long.MAX_VALUE). + queryEndPosition = Long.MAX_VALUE; + } long currentEndPosition = span.position + span.length; if (currentEndPosition < queryEndPosition) { for (SimpleCacheSpan next : cachedSpans.tailSet(span, false)) { @@ -153,7 +161,7 @@ import java.util.TreeSet; */ public SimpleCacheSpan setLastTouchTimestamp( SimpleCacheSpan cacheSpan, long lastTouchTimestamp, boolean updateFile) { - Assertions.checkState(cachedSpans.remove(cacheSpan)); + checkState(cachedSpans.remove(cacheSpan)); File file = cacheSpan.file; if (updateFile) { File directory = file.getParentFile(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index f2406f9922..c61a3533d6 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -300,38 +300,93 @@ public class SimpleCacheTest { } @Test - public void getCachedLength() throws Exception { + public void getCachedLength_noCachedContent_returnsNegativeMaxHoleLength() { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); - // No cached bytes, returns -'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(-100); - - // Position value doesn't affect the return value - assertThat(simpleCache.getCachedLength(KEY_1, 20, 100)).isEqualTo(-100); - - addCache(simpleCache, KEY_1, 0, 15); - - // Returns the length of a single span - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(15); - - // Value is capped by the 'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 10)).isEqualTo(10); - - addCache(simpleCache, KEY_1, 15, 35); - - // Returns the length of two adjacent spans - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); - - addCache(simpleCache, KEY_1, 60, 10); - - // Not adjacent span doesn't affect return value - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); - - // Returns length of hole up to the next cached span - assertThat(simpleCache.getCachedLength(KEY_1, 55, 100)).isEqualTo(-5); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + } + @Test + public void getCachedLength_returnsNegativeHoleLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 50, /* length= */ 50); simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-30); + } + + @Test + public void getCachedLength_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 50); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); + addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleNonAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); + addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); } /* Tests https://github.com/google/ExoPlayer/issues/3260 case. */ From a42a1f49ed6e8cf4f1709f56b4a6662fcb42ee77 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 19 May 2020 15:35:58 +0100 Subject: [PATCH 0278/1052] Allow passing C.LENGTH_UNSET to getCachedLength The caller will often have C.LENGTH_UNSET already, and it's awkward to force them to do the conversion themselves. PiperOrigin-RevId: 312276810 --- .../exoplayer2/upstream/cache/Cache.java | 3 ++- .../exoplayer2/upstream/cache/CacheUtil.java | 12 ++++------- .../upstream/cache/SimpleCache.java | 3 +++ .../upstream/cache/CacheUtilTest.java | 3 +++ .../upstream/cache/SimpleCacheTest.java | 20 +++++++++++++++++++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index fe7d34850b..781b5619d0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -272,7 +272,8 @@ public interface Cache { * * @param key The cache key of the resource. * @param position The starting position of the data in the resource. - * @param length The maximum length of the data or hole to be returned. + * @param length The maximum length of the data or hole to be returned. {@link C#LENGTH_UNSET} is + * permitted, and is equivalent to passing {@link Long#MAX_VALUE}. * @return The length of the continuously cached data, or {@code -holeLength} if {@code position} * isn't cached. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index 47b61780d8..57c9ecbf55 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -74,9 +74,7 @@ public final class CacheUtil { long bytesAlreadyCached = 0; long bytesLeft = requestLength; while (bytesLeft != 0) { - long blockLength = - cache.getCachedLength( - key, position, bytesLeft != C.LENGTH_UNSET ? bytesLeft : Long.MAX_VALUE); + long blockLength = cache.getCachedLength(key, position, bytesLeft); if (blockLength > 0) { bytesAlreadyCached += blockLength; } else { @@ -173,11 +171,9 @@ public final class CacheUtil { } long position = dataSpec.position; - boolean lengthUnset = bytesLeft == C.LENGTH_UNSET; while (bytesLeft != 0) { throwExceptionIfCanceled(isCanceled); - long blockLength = - cache.getCachedLength(key, position, lengthUnset ? Long.MAX_VALUE : bytesLeft); + long blockLength = cache.getCachedLength(key, position, bytesLeft); if (blockLength > 0) { // Skip already cached data. } else { @@ -197,14 +193,14 @@ public final class CacheUtil { temporaryBuffer); if (read < blockLength) { // Reached to the end of the data. - if (enableEOFException && !lengthUnset) { + if (enableEOFException && bytesLeft != C.LENGTH_UNSET) { throw new EOFException(); } break; } } position += blockLength; - if (!lengthUnset) { + if (bytesLeft != C.LENGTH_UNSET) { bytesLeft -= blockLength; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 0cb379b241..c71d8e2714 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -494,6 +494,9 @@ public final class SimpleCache implements Cache { @Override public synchronized long getCachedLength(String key, long position, long length) { Assertions.checkState(!released); + if (length == C.LENGTH_UNSET) { + length = Long.MAX_VALUE; + } @Nullable CachedContent cachedContent = contentIndex.get(key); return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java index c4115cbc28..eba664648b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -63,6 +63,9 @@ public final class CacheUtilTest { @Override public long getCachedLength(String key, long position, long length) { + if (length == C.LENGTH_UNSET) { + length = Long.MAX_VALUE; + } for (int i = 0; i < spansAndGaps.length; i++) { int spanOrGap = spansAndGaps[i]; if (position < spanOrGap) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index c61a3533d6..73cde3dee4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -307,10 +307,14 @@ public class SimpleCacheTest { .isEqualTo(-100); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(-Long.MAX_VALUE); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) .isEqualTo(-100); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(-Long.MAX_VALUE); } @Test @@ -324,10 +328,14 @@ public class SimpleCacheTest { .isEqualTo(-50); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(-50); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) .isEqualTo(-30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) .isEqualTo(-30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(-30); } @Test @@ -341,10 +349,14 @@ public class SimpleCacheTest { .isEqualTo(50); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(50); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) .isEqualTo(30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) .isEqualTo(15); } @@ -361,10 +373,14 @@ public class SimpleCacheTest { .isEqualTo(50); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(50); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) .isEqualTo(30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) .isEqualTo(15); } @@ -381,10 +397,14 @@ public class SimpleCacheTest { .isEqualTo(10); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(10); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) .isEqualTo(30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) .isEqualTo(15); } From e87221c93897676f54884cb3e86f30724b9b378c Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 19 May 2020 23:29:30 +0100 Subject: [PATCH 0279/1052] Add Cache.getCachedBytes This will replace the need to use CacheUtil.getCached, and is part of refactoring CacheUtil to only do writing (it will be renamed to CacheWriter in a subsequent change). PiperOrigin-RevId: 312366040 --- .../exoplayer2/upstream/cache/Cache.java | 12 ++++ .../upstream/cache/SimpleCache.java | 23 +++++++ .../upstream/cache/SimpleCacheTest.java | 66 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index 781b5619d0..33f0dc35f2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -279,6 +279,18 @@ public interface Cache { */ long getCachedLength(String key, long position, long length); + /** + * Returns the total number of cached bytes between {@code position} (inclusive) and {@code + * (position + length)} (exclusive) of a resource. + * + * @param key The cache key of the resource. + * @param position The starting position of the data in the resource. + * @param length The length of the data to check. {@link C#LENGTH_UNSET} is permitted, and is + * equivalent to passing {@link Long#MAX_VALUE}. + * @return The total number of cached bytes. + */ + long getCachedBytes(String key, long position, long length); + /** * Applies {@code mutations} to the {@link ContentMetadata} for the given resource. A new {@link * CachedContent} is added if there isn't one already for the resource. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index c71d8e2714..1cb6d13fc0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -501,6 +501,29 @@ public final class SimpleCache implements Cache { return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length; } + @Override + public synchronized long getCachedBytes(String key, long position, long length) { + long endPosition = length == C.LENGTH_UNSET ? Long.MAX_VALUE : position + length; + if (endPosition < 0) { + // The calculation rolled over (length is probably Long.MAX_VALUE). + endPosition = Long.MAX_VALUE; + } + long currentPosition = position; + long cachedBytes = 0; + while (currentPosition < endPosition) { + long maxRemainingLength = endPosition - currentPosition; + long blockLength = getCachedLength(key, currentPosition, maxRemainingLength); + if (blockLength > 0) { + cachedBytes += blockLength; + } else { + // There's a hole of length -blockLength. + blockLength = -blockLength; + } + currentPosition += blockLength; + } + return cachedBytes; + } + @Override public synchronized void applyContentMetadataMutations( String key, ContentMetadataMutations mutations) throws CacheException { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 73cde3dee4..2b43ff9d3b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -409,6 +409,72 @@ public class SimpleCacheTest { .isEqualTo(15); } + @Test + public void getCachedBytes_noCachedContent_returnsZero() { + SimpleCache simpleCache = getSimpleCache(); + + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(0); + } + + @Test + public void getCachedBytes_withMultipleAdjacentSpans_returnsCachedBytes() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); + addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(50); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedBytes_withMultipleNonAdjacentSpans_returnsCachedBytes() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); + addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(45); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(45); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(45); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 10)) + .isEqualTo(10); + } + /* Tests https://github.com/google/ExoPlayer/issues/3260 case. */ @Test public void exceptionDuringEvictionByLeastRecentlyUsedCacheEvictorNotHang() throws Exception { From b9157a9e232a9a8b1e1c1ce3e68f924d853a5c52 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 20 May 2020 10:53:24 +0100 Subject: [PATCH 0280/1052] Add Guava dependency to ExoPlayer Guava is heavily optimized for Android and the impact on binary size is minimal (and outweighed by the organic growth of the ExoPlayer library). This change also replaces Util.toArray() with Guava's Ints.toArray() in order to introduce a Guava usage into a range of modules. PiperOrigin-RevId: 312449093 --- RELEASENOTES.md | 1 + constants.gradle | 2 +- javadoc_combined.gradle | 4 +- javadoc_library.gradle | 4 +- library/common/build.gradle | 1 + .../google/android/exoplayer2/util/Util.java | 55 ++----------------- .../android/exoplayer2/util/UtilTest.java | 19 +------ library/core/build.gradle | 1 + .../source/DefaultMediaSourceFactory.java | 3 +- .../trackselection/DefaultTrackSelector.java | 3 +- library/dash/build.gradle | 1 + .../source/dash/DashMediaPeriod.java | 3 +- library/hls/build.gradle | 1 + .../exoplayer2/source/hls/HlsMediaPeriod.java | 3 +- .../playbacktests/gts/DashTestRunner.java | 3 +- 15 files changed, 27 insertions(+), 77 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f00b7b1793..6dafa1530a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -227,6 +227,7 @@ * MediaSession extension: Set session playback state to BUFFERING only when actually playing ([#7367](https://github.com/google/ExoPlayer/pull/7367), [#7206](https://github.com/google/ExoPlayer/issues/7206)). +* Add Guava dependency. ### 2.11.4 (2020-04-08) diff --git a/constants.gradle b/constants.gradle index f3bebf6038..b0a0e4fce5 100644 --- a/constants.gradle +++ b/constants.gradle @@ -21,7 +21,7 @@ project.ext { compileSdkVersion = 29 dexmakerVersion = '2.21.0' junitVersion = '4.13-rc-2' - guavaVersion = '28.2-android' + guavaVersion = '27.1-android' mockitoVersion = '2.25.0' robolectricVersion = '4.4-SNAPSHOT' checkerframeworkVersion = '2.5.0' diff --git a/javadoc_combined.gradle b/javadoc_combined.gradle index 3b482910ae..1030d3e16a 100644 --- a/javadoc_combined.gradle +++ b/javadoc_combined.gradle @@ -11,6 +11,7 @@ // 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. +apply from: "${buildscript.sourceFile.parentFile}/constants.gradle" apply from: "${buildscript.sourceFile.parentFile}/javadoc_util.gradle" class CombinedJavadocPlugin implements Plugin { @@ -29,7 +30,8 @@ class CombinedJavadocPlugin implements Plugin { classpath = project.files([]) destinationDir = project.file("$project.buildDir/docs/javadoc") options { - links "https://developer.android.com/reference" + links "https://developer.android.com/reference", + "https://guava.dev/releases/$project.ext.guavaVersion/api/docs" encoding = "UTF-8" } exclude "**/BuildConfig.java" diff --git a/javadoc_library.gradle b/javadoc_library.gradle index dd508a1781..f135e3a624 100644 --- a/javadoc_library.gradle +++ b/javadoc_library.gradle @@ -11,6 +11,7 @@ // 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. +apply from: "${buildscript.sourceFile.parentFile}/constants.gradle" apply from: "${buildscript.sourceFile.parentFile}/javadoc_util.gradle" android.libraryVariants.all { variant -> @@ -26,7 +27,8 @@ android.libraryVariants.all { variant -> title = "ExoPlayer ${javadocTitle}" source = allSourceDirs options { - links "https://developer.android.com/reference" + links "https://developer.android.com/reference", + "https://guava.dev/releases/$project.ext.guavaVersion/api/docs" encoding = "UTF-8" } exclude "**/BuildConfig.java" diff --git a/library/common/build.gradle b/library/common/build.gradle index 9dc3aabac3..03c5b9c4b6 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -39,6 +39,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version + compileOnly 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 75064623f6..888f0afa16 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -53,6 +53,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.common.base.Ascii; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.File; @@ -63,7 +64,6 @@ import java.math.BigDecimal; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.Charset; -import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; @@ -1234,41 +1234,6 @@ public final class Util { return Math.round((double) mediaDuration / speed); } - /** - * Converts a list of integers to a primitive array. - * - * @param list A list of integers. - * @return The list in array form, or null if the input list was null. - */ - public static int @PolyNull [] toArray(@PolyNull List list) { - if (list == null) { - return null; - } - int length = list.size(); - int[] intArray = new int[length]; - for (int i = 0; i < length; i++) { - intArray[i] = list.get(i); - } - return intArray; - } - - /** - * Converts an array of primitive ints to a list of integers. - * - * @param ints The ints. - * @return The input array in list form. - */ - public static List toList(int... ints) { - if (ints == null) { - return new ArrayList<>(); - } - List integers = new ArrayList<>(); - for (int anInt : ints) { - integers.add(anInt); - } - return integers; - } - /** * Returns the integer equal to the big-endian concatenation of the characters in {@code string} * as bytes. The string must be no more than four characters long. @@ -1312,6 +1277,9 @@ public final class Util { /** * Truncates a sequence of ASCII characters to a maximum length. * + *

    This preserves span styling in the {@link CharSequence}. If that's not important, use {@link + * Ascii#truncate(CharSequence, int, String)}. + * *

    Note: This is not safe to use in general on Unicode text because it may separate * characters from combining characters or split up surrogate pairs. * @@ -1324,21 +1292,6 @@ public final class Util { return sequence.length() <= maxLength ? sequence : sequence.subSequence(0, maxLength); } - /** - * Truncates a string of ASCII characters to a maximum length. - * - *

    Note: This is not safe to use in general on Unicode text because it may separate - * characters from combining characters or split up surrogate pairs. - * - * @param string The string to truncate. - * @param maxLength The max length to truncate to. - * @return {@code string} directly if {@code string.length() <= maxLength}, otherwise {@code - * string.substring(0, maxLength}. - */ - public static String truncateAscii(String string, int maxLength) { - return string.length() <= maxLength ? string : string.substring(0, maxLength); - } - /** * Returns a byte array containing values parsed from the hex string provided. * diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java index 861267fc3a..de51274697 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java @@ -723,15 +723,13 @@ public class UtilTest { String input = "a short string"; assertThat(Util.truncateAscii(input, 100)).isSameInstanceAs(input); - assertThat(Util.truncateAscii((CharSequence) input, 100)).isSameInstanceAs(input); } @Test public void truncateAscii_longInput_truncated() { String input = "a much longer string"; - assertThat(Util.truncateAscii(input, 5)).isEqualTo("a muc"); - assertThat(Util.truncateAscii((CharSequence) input, 5).toString()).isEqualTo("a muc"); + assertThat(Util.truncateAscii(input, 5).toString()).isEqualTo("a muc"); } @Test @@ -999,21 +997,6 @@ public class UtilTest { assertThat(Util.normalizeLanguageCode("hsn")).isEqualTo("zh-hsn"); } - @Test - public void toList() { - assertThat(Util.toList(0, 3, 4)).containsExactly(0, 3, 4).inOrder(); - } - - @Test - public void toList_nullPassed_returnsEmptyList() { - assertThat(Util.toList(null)).isEmpty(); - } - - @Test - public void toList_emptyArrayPassed_returnsEmptyList() { - assertThat(Util.toList(new int[0])).isEmpty(); - } - private static void assertEscapeUnescapeFileName(String fileName, String escapedFileName) { assertThat(escapeFileName(fileName)).isEqualTo(escapedFileName); assertThat(unescapeFileName(escapedFileName)).isEqualTo(fileName); diff --git a/library/core/build.gradle b/library/core/build.gradle index 8b8c3fd520..a1d04dcf7b 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -54,6 +54,7 @@ dependencies { api project(modulePrefix + 'library-extractor') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version + compileOnly 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index a164a1348d..98bede87eb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -41,6 +41,7 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -323,7 +324,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { .setPlayClearSamplesWithoutKeys( mediaItem.playbackProperties.drmConfiguration.playClearContentWithoutKey) .setUseDrmSessionsForClearContent( - Util.toArray(mediaItem.playbackProperties.drmConfiguration.sessionForClearTypes)) + Ints.toArray(mediaItem.playbackProperties.drmConfiguration.sessionForClearTypes)) .build(createHttpMediaDrmCallback(mediaItem.playbackProperties.drmConfiguration)); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 668202993a..5bdc7847b6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -39,6 +39,7 @@ import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -1822,7 +1823,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoBitrate, selectedTrackIndices); - return selectedTrackIndices.size() < 2 ? NO_TRACKS : Util.toArray(selectedTrackIndices); + return selectedTrackIndices.size() < 2 ? NO_TRACKS : Ints.toArray(selectedTrackIndices); } private static int getAdaptiveVideoTrackCountForMimeType( diff --git a/library/dash/build.gradle b/library/dash/build.gradle index 0ffbc718f0..51fb08837a 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -40,6 +40,7 @@ android { dependencies { implementation project(modulePrefix + 'library-core') + compileOnly 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index f0ab422f5e..712e7137cd 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -51,6 +51,7 @@ import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -604,7 +605,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int[][] groupedAdaptationSetIndices = new int[adaptationSetGroupedIndices.size()][]; for (int i = 0; i < groupedAdaptationSetIndices.length; i++) { - groupedAdaptationSetIndices[i] = Util.toArray(adaptationSetGroupedIndices.get(i)); + groupedAdaptationSetIndices[i] = Ints.toArray(adaptationSetGroupedIndices.get(i)); // Restore the original adaptation set order within each group. Arrays.sort(groupedAdaptationSetIndices[i]); } diff --git a/library/hls/build.gradle b/library/hls/build.gradle index 4764cf9882..e6153d904f 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -38,6 +38,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + compileOnly 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index b6985a836c..8b6f51571d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -46,6 +46,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -719,7 +720,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper /* muxedCaptionFormats= */ Collections.emptyList(), overridingDrmInitData, positionUs); - manifestUrlsIndicesPerWrapper.add(Util.toArray(scratchIndicesList)); + manifestUrlsIndicesPerWrapper.add(Ints.toArray(scratchIndicesList)); sampleStreamWrappers.add(sampleStreamWrapper); if (allowChunklessPreparation && renditionsHaveCodecs) { diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java index 5f96b961f6..ea745ed257 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java @@ -56,6 +56,7 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -444,7 +445,7 @@ import java.util.List; } } - int[] trackIndicesArray = Util.toArray(trackIndices); + int[] trackIndicesArray = Ints.toArray(trackIndices); Arrays.sort(trackIndicesArray); return trackIndicesArray; } From d233c04582feba5ec3de1b6f0ab1377f4244a7bf Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 20 May 2020 14:03:15 +0100 Subject: [PATCH 0281/1052] Simplify DownloadHelper PiperOrigin-RevId: 312467496 --- .../exoplayer2/demo/DownloadTracker.java | 27 +- library/core/proguard-rules.txt | 2 +- .../exoplayer2/offline/DownloadHelper.java | 601 +++++++++--------- .../offline/DownloadHelperTest.java | 20 +- .../dash/offline/DownloadHelperTest.java | 21 +- .../hls/offline/DownloadHelperTest.java | 27 +- .../offline/DownloadHelperTest.java | 21 +- 7 files changed, 342 insertions(+), 377 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index 3127ed95e9..a36635acb0 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -24,7 +24,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentManager; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.offline.Download; @@ -106,7 +105,9 @@ public class DownloadTracker { } startDownloadDialogHelper = new StartDownloadDialogHelper( - fragmentManager, getDownloadHelper(mediaItem, renderersFactory), mediaItem); + fragmentManager, + DownloadHelper.forMediaItem(context, mediaItem, renderersFactory, dataSourceFactory), + mediaItem); } } @@ -121,28 +122,6 @@ public class DownloadTracker { } } - private DownloadHelper getDownloadHelper(MediaItem mediaItem, RenderersFactory renderersFactory) { - MediaItem.PlaybackProperties playbackProperties = checkNotNull(mediaItem.playbackProperties); - @C.ContentType - int type = - Util.inferContentTypeWithMimeType(playbackProperties.uri, playbackProperties.mimeType); - switch (type) { - case C.TYPE_DASH: - return DownloadHelper.forDash( - context, playbackProperties.uri, dataSourceFactory, renderersFactory); - case C.TYPE_SS: - return DownloadHelper.forSmoothStreaming( - context, playbackProperties.uri, dataSourceFactory, renderersFactory); - case C.TYPE_HLS: - return DownloadHelper.forHls( - context, playbackProperties.uri, dataSourceFactory, renderersFactory); - case C.TYPE_OTHER: - return DownloadHelper.forProgressive(context, playbackProperties.uri); - default: - throw new IllegalStateException("Unsupported type: " + type); - } - } - private class DownloadManagerListener implements DownloadManager.Listener { @Override diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index cbeb74cf6c..9578bd869b 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -62,7 +62,7 @@ (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); } -# Constructors accessed via reflection in DefaultMediaSourceFactory and DownloadHelper +# Constructors accessed via reflection in DefaultMediaSourceFactory -dontnote com.google.android.exoplayer2.source.dash.DashMediaSource$Factory -keepclasseswithmembers class com.google.android.exoplayer2.source.dash.DashMediaSource$Factory { (com.google.android.exoplayer2.upstream.DataSource$Factory); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 8e50d70020..11933e7834 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.offline; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.content.Context; import android.net.Uri; import android.os.Handler; @@ -24,18 +27,18 @@ import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; -import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.chunk.MediaChunk; @@ -50,14 +53,13 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSource.Factory; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.io.IOException; -import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -144,18 +146,6 @@ public final class DownloadHelper { /** Thrown at an attempt to download live content. */ public static class LiveContentUnsupportedException extends IOException {} - @Nullable - private static final Constructor DASH_FACTORY_CONSTRUCTOR = - getConstructor("com.google.android.exoplayer2.source.dash.DashMediaSource$Factory"); - - @Nullable - private static final Constructor SS_FACTORY_CONSTRUCTOR = - getConstructor("com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory"); - - @Nullable - private static final Constructor HLS_FACTORY_CONSTRUCTOR = - getConstructor("com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory"); - /** * Extracts renderer capabilities for the renderers created by the provided renderers factory. * @@ -178,262 +168,264 @@ public final class DownloadHelper { return capabilities; } - /** @deprecated Use {@link #forProgressive(Context, Uri)} */ + /** @deprecated Use {@link #forMediaItem(Context, MediaItem)} */ @Deprecated - @SuppressWarnings("deprecation") - public static DownloadHelper forProgressive(Uri uri) { - return forProgressive(uri, /* cacheKey= */ null); - } - - /** - * Creates a {@link DownloadHelper} for progressive streams. - * - * @param context Any {@link Context}. - * @param uri A stream {@link Uri}. - * @return A {@link DownloadHelper} for progressive streams. - */ public static DownloadHelper forProgressive(Context context, Uri uri) { - return forProgressive(context, uri, /* cacheKey= */ null); + return forMediaItem(context, new MediaItem.Builder().setUri(uri).build()); } - /** @deprecated Use {@link #forProgressive(Context, Uri, String)} */ + /** @deprecated Use {@link #forMediaItem(Context, MediaItem)} */ @Deprecated - public static DownloadHelper forProgressive(Uri uri, @Nullable String cacheKey) { - return new DownloadHelper( - DownloadRequest.TYPE_PROGRESSIVE, - uri, - cacheKey, - /* mediaSource= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, - /* rendererCapabilities= */ new RendererCapabilities[0]); - } - - /** - * Creates a {@link DownloadHelper} for progressive streams. - * - * @param context Any {@link Context}. - * @param uri A stream {@link Uri}. - * @param cacheKey An optional cache key. - * @return A {@link DownloadHelper} for progressive streams. - */ public static DownloadHelper forProgressive(Context context, Uri uri, @Nullable String cacheKey) { - return new DownloadHelper( - DownloadRequest.TYPE_PROGRESSIVE, + return forMediaItem( + context, new MediaItem.Builder().setUri(uri).setCustomCacheKey(cacheKey).build()); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forDash( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forDash( uri, - cacheKey, - /* mediaSource= */ null, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory, DrmSessionManager)} instead. + */ + @Deprecated + public static DownloadHelper forDash( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(), + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + drmSessionManager); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forHls( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forHls( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory, DrmSessionManager)} instead. + */ + @Deprecated + public static DownloadHelper forHls( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build(), + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + drmSessionManager); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forSmoothStreaming( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forSmoothStreaming( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory, DrmSessionManager)} instead. + */ + @Deprecated + public static DownloadHelper forSmoothStreaming( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_SS).build(), + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + drmSessionManager); + } + + /** + * Creates a {@link DownloadHelper} for the given progressive media item. + * + * @param context The context. + * @param mediaItem A {@link MediaItem}. + * @return A {@link DownloadHelper} for progressive streams. + * @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming. + */ + public static DownloadHelper forMediaItem(Context context, MediaItem mediaItem) { + Assertions.checkArgument( + DownloadRequest.TYPE_PROGRESSIVE.equals( + getDownloadType(checkNotNull(mediaItem.playbackProperties)))); + return forMediaItem( + mediaItem, getDefaultTrackSelectorParameters(context), - /* rendererCapabilities= */ new RendererCapabilities[0]); - } - - /** @deprecated Use {@link #forDash(Context, Uri, Factory, RenderersFactory)} */ - @Deprecated - public static DownloadHelper forDash( - Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { - return forDash( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + /* renderersFactory= */ null, + /* dataSourceFactory= */ null, + /* drmSessionManager= */ null); } /** - * Creates a {@link DownloadHelper} for DASH streams. + * Creates a {@link DownloadHelper} for the given media item. * - * @param context Any {@link Context}. - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param context The context. + * @param mediaItem A {@link MediaItem}. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @return A {@link DownloadHelper} for DASH streams. - * @throws IllegalStateException If the DASH module is missing. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams. This argument is required for adaptive streams and ignored for progressive + * streams. + * @return A {@link DownloadHelper}. + * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ - public static DownloadHelper forDash( + public static DownloadHelper forMediaItem( Context context, - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory) { - return forDash( - uri, - dataSourceFactory, + MediaItem mediaItem, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, + getDefaultTrackSelectorParameters(context), renderersFactory, - /* drmSessionManager= */ null, - getDefaultTrackSelectorParameters(context)); + dataSourceFactory, + /* drmSessionManager= */ null); } /** - * Creates a {@link DownloadHelper} for DASH streams. + * Creates a {@link DownloadHelper} for the given media item. * - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param mediaItem A {@link MediaItem}. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which - * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. - * @return A {@link DownloadHelper} for DASH streams. - * @throws IllegalStateException If the DASH module is missing. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams. This argument is required for adaptive streams and ignored for progressive + * streams. + * @return A {@link DownloadHelper}. + * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ - public static DownloadHelper forDash( - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager, - DefaultTrackSelector.Parameters trackSelectorParameters) { - return new DownloadHelper( - DownloadRequest.TYPE_DASH, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - DASH_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), + public static DownloadHelper forMediaItem( + MediaItem mediaItem, + DefaultTrackSelector.Parameters trackSelectorParameters, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, trackSelectorParameters, - getRendererCapabilities(renderersFactory)); - } - - /** @deprecated Use {@link #forHls(Context, Uri, Factory, RenderersFactory)} */ - @Deprecated - public static DownloadHelper forHls( - Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { - return forHls( - uri, - dataSourceFactory, renderersFactory, - /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + dataSourceFactory, + /* drmSessionManager= */ null); } /** - * Creates a {@link DownloadHelper} for HLS streams. + * Creates a {@link DownloadHelper} for the given media item. * - * @param context Any {@link Context}. - * @param uri A playlist {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. + * @param mediaItem A {@link MediaItem}. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @return A {@link DownloadHelper} for HLS streams. - * @throws IllegalStateException If the HLS module is missing. - */ - public static DownloadHelper forHls( - Context context, - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory) { - return forHls( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - getDefaultTrackSelectorParameters(context)); - } - - /** - * Creates a {@link DownloadHelper} for HLS streams. - * - * @param uri A playlist {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. - * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are - * selected. - * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which - * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. - * @return A {@link DownloadHelper} for HLS streams. - * @throws IllegalStateException If the HLS module is missing. - */ - public static DownloadHelper forHls( - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager, - DefaultTrackSelector.Parameters trackSelectorParameters) { - return new DownloadHelper( - DownloadRequest.TYPE_HLS, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - HLS_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), - trackSelectorParameters, - getRendererCapabilities(renderersFactory)); - } - - /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */ - @Deprecated - public static DownloadHelper forSmoothStreaming( - Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { - return forSmoothStreaming( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); - } - - /** - * Creates a {@link DownloadHelper} for SmoothStreaming streams. - * - * @param context Any {@link Context}. - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are - * selected. - * @return A {@link DownloadHelper} for SmoothStreaming streams. - * @throws IllegalStateException If the SmoothStreaming module is missing. - */ - public static DownloadHelper forSmoothStreaming( - Context context, - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory) { - return forSmoothStreaming( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - getDefaultTrackSelectorParameters(context)); - } - - /** - * Creates a {@link DownloadHelper} for SmoothStreaming streams. - * - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are - * selected. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams. This argument is required for adaptive streams and ignored for progressive + * streams. * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which * tracks can be selected. - * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for - * downloading. - * @return A {@link DownloadHelper} for SmoothStreaming streams. - * @throws IllegalStateException If the SmoothStreaming module is missing. + * @return A {@link DownloadHelper}. + * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ - public static DownloadHelper forSmoothStreaming( - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager, - DefaultTrackSelector.Parameters trackSelectorParameters) { + public static DownloadHelper forMediaItem( + MediaItem mediaItem, + DefaultTrackSelector.Parameters trackSelectorParameters, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory, + @Nullable DrmSessionManager drmSessionManager) { + boolean isProgressive = + DownloadRequest.TYPE_PROGRESSIVE.equals( + getDownloadType(checkNotNull(mediaItem.playbackProperties))); + Assertions.checkArgument(isProgressive || dataSourceFactory != null); return new DownloadHelper( - DownloadRequest.TYPE_SS, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - SS_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), + mediaItem, + isProgressive + ? null + : createMediaSourceInternal( + mediaItem, castNonNull(dataSourceFactory), drmSessionManager), trackSelectorParameters, - getRendererCapabilities(renderersFactory)); + renderersFactory != null + ? getRendererCapabilities(renderersFactory) + : new RendererCapabilities[0]); } /** @@ -459,35 +451,18 @@ public final class DownloadHelper { DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { - @Nullable Constructor constructor; - switch (downloadRequest.type) { - case DownloadRequest.TYPE_DASH: - constructor = DASH_FACTORY_CONSTRUCTOR; - break; - case DownloadRequest.TYPE_SS: - constructor = SS_FACTORY_CONSTRUCTOR; - break; - case DownloadRequest.TYPE_HLS: - constructor = HLS_FACTORY_CONSTRUCTOR; - break; - case DownloadRequest.TYPE_PROGRESSIVE: - return new ProgressiveMediaSource.Factory(dataSourceFactory) - .setCustomCacheKey(downloadRequest.customCacheKey) - .createMediaSource(downloadRequest.uri); - default: - throw new IllegalStateException("Unsupported type: " + downloadRequest.type); - } return createMediaSourceInternal( - constructor, - downloadRequest.uri, + new MediaItem.Builder() + .setUri(downloadRequest.uri) + .setCustomCacheKey(downloadRequest.customCacheKey) + .setMimeType(getMimeType(downloadRequest.type)) + .setStreamKeys(downloadRequest.streamKeys) + .build(), dataSourceFactory, - drmSessionManager, - downloadRequest.streamKeys); + drmSessionManager); } - private final String downloadType; - private final Uri uri; - @Nullable private final String cacheKey; + private final MediaItem.PlaybackProperties playbackProperties; @Nullable private final MediaSource mediaSource; private final DefaultTrackSelector trackSelector; private final RendererCapabilities[] rendererCapabilities; @@ -506,9 +481,7 @@ public final class DownloadHelper { /** * Creates download helper. * - * @param downloadType A download type. This value will be used as {@link DownloadRequest#type}. - * @param uri A {@link Uri}. - * @param cacheKey An optional cache key. + * @param mediaItem The media item. * @param mediaSource A {@link MediaSource} for which tracks are selected, or null if no track * selection needs to be made. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for @@ -517,15 +490,11 @@ public final class DownloadHelper { * are selected. */ public DownloadHelper( - String downloadType, - Uri uri, - @Nullable String cacheKey, + MediaItem mediaItem, @Nullable MediaSource mediaSource, DefaultTrackSelector.Parameters trackSelectorParameters, RendererCapabilities[] rendererCapabilities) { - this.downloadType = downloadType; - this.uri = uri; - this.cacheKey = cacheKey; + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); this.mediaSource = mediaSource; this.trackSelector = new DefaultTrackSelector(trackSelectorParameters, new DownloadTrackSelection.Factory()); @@ -766,7 +735,7 @@ public final class DownloadHelper { * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(@Nullable byte[] data) { - return getDownloadRequest(uri.toString(), data); + return getDownloadRequest(playbackProperties.uri.toString(), data); } /** @@ -778,9 +747,15 @@ public final class DownloadHelper { * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { + String downloadType = getDownloadType(playbackProperties); if (mediaSource == null) { return new DownloadRequest( - id, downloadType, uri, /* streamKeys= */ Collections.emptyList(), cacheKey, data); + id, + downloadType, + playbackProperties.uri, + /* streamKeys= */ Collections.emptyList(), + playbackProperties.customCacheKey, + data); } assertPreparedWithMedia(); List streamKeys = new ArrayList<>(); @@ -794,15 +769,21 @@ public final class DownloadHelper { } streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); } - return new DownloadRequest(id, downloadType, uri, streamKeys, cacheKey, data); + return new DownloadRequest( + id, + downloadType, + playbackProperties.uri, + streamKeys, + playbackProperties.customCacheKey, + data); } // Initialization of array of Lists. @SuppressWarnings("unchecked") private void onMediaPrepared() { - Assertions.checkNotNull(mediaPreparer); - Assertions.checkNotNull(mediaPreparer.mediaPeriods); - Assertions.checkNotNull(mediaPreparer.timeline); + checkNotNull(mediaPreparer); + checkNotNull(mediaPreparer.mediaPeriods); + checkNotNull(mediaPreparer.timeline); int periodCount = mediaPreparer.mediaPeriods.length; int rendererCount = rendererCapabilities.length; trackSelectionsByPeriodAndRenderer = @@ -822,16 +803,14 @@ public final class DownloadHelper { trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups(); TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); trackSelector.onSelectionActivated(trackSelectorResult.info); - mappedTrackInfos[i] = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + mappedTrackInfos[i] = checkNotNull(trackSelector.getCurrentMappedTrackInfo()); } setPreparedWithMedia(); - Assertions.checkNotNull(callbackHandler) - .post(() -> Assertions.checkNotNull(callback).onPrepared(this)); + checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepared(this)); } private void onMediaPreparationFailed(IOException error) { - Assertions.checkNotNull(callbackHandler) - .post(() -> Assertions.checkNotNull(callback).onPrepareError(this, error)); + checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepareError(this, error)); } @RequiresNonNull({ @@ -921,43 +900,43 @@ public final class DownloadHelper { } } - @Nullable - private static Constructor getConstructor(String className) { - try { - // LINT.IfChange - Class factoryClazz = - Class.forName(className).asSubclass(MediaSourceFactory.class); - return factoryClazz.getConstructor(Factory.class); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the respective module. - return null; - } catch (NoSuchMethodException e) { - // Something is wrong with the library or the proguard configuration. - throw new IllegalStateException(e); + private static MediaSource createMediaSourceInternal( + MediaItem mediaItem, + DataSource.Factory dataSourceFactory, + @Nullable DrmSessionManager drmSessionManager) { + return new DefaultMediaSourceFactory(dataSourceFactory, /* adSupportProvider= */ null) + .setDrmSessionManager(drmSessionManager) + .createMediaSource(mediaItem); + } + + private static String getDownloadType(MediaItem.PlaybackProperties playbackProperties) { + int contentType = + Util.inferContentTypeWithMimeType(playbackProperties.uri, playbackProperties.mimeType); + switch (contentType) { + case C.TYPE_DASH: + return DownloadRequest.TYPE_DASH; + case C.TYPE_HLS: + return DownloadRequest.TYPE_HLS; + case C.TYPE_SS: + return DownloadRequest.TYPE_SS; + default: + return DownloadRequest.TYPE_PROGRESSIVE; } } - private static MediaSource createMediaSourceInternal( - @Nullable Constructor constructor, - Uri uri, - Factory dataSourceFactory, - @Nullable DrmSessionManager drmSessionManager, - @Nullable List streamKeys) { - if (constructor == null) { - throw new IllegalStateException("Module missing to create media source."); - } - try { - MediaSourceFactory factory = constructor.newInstance(dataSourceFactory); - if (drmSessionManager != null) { - factory.setDrmSessionManager(drmSessionManager); - } - if (streamKeys != null) { - factory.setStreamKeys(streamKeys); - } - return Assertions.checkNotNull(factory.createMediaSource(uri)); - } catch (Exception e) { - throw new IllegalStateException("Failed to instantiate media source.", e); + @Nullable + private static String getMimeType(String downloadType) { + switch (downloadType) { + case DownloadRequest.TYPE_DASH: + return MimeTypes.APPLICATION_MPD; + case DownloadRequest.TYPE_HLS: + return MimeTypes.APPLICATION_M3U8; + case DownloadRequest.TYPE_SS: + return MimeTypes.APPLICATION_SS; + case DownloadRequest.TYPE_PROGRESSIVE: + return null; + default: + throw new IllegalArgumentException(); } } @@ -1115,7 +1094,7 @@ public final class DownloadHelper { return true; case DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED: release(); - downloadHelper.onMediaPreparationFailed((IOException) Util.castNonNull(msg.obj)); + downloadHelper.onMediaPreparationFailed((IOException) castNonNull(msg.obj)); return true; default: return false; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index 5fa9ae082f..b8edff6f60 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -18,11 +18,11 @@ package com.google.android.exoplayer2.offline; import static com.google.common.truth.Truth.assertThat; import static org.robolectric.shadows.ShadowBaseLooper.shadowMainLooper; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.Timeline; @@ -59,8 +59,6 @@ import org.robolectric.annotation.LooperMode; @LooperMode(LooperMode.Mode.PAUSED) public class DownloadHelperTest { - private static final String TEST_DOWNLOAD_TYPE = "downloadType"; - private static final String TEST_CACHE_KEY = "cacheKey"; private static final Object TEST_MANIFEST = new Object(); private static final Timeline TEST_TIMELINE = new FakeTimeline( @@ -86,7 +84,7 @@ public class DownloadHelperTest { private static TrackGroupArray trackGroupArraySingle; private static TrackGroupArray[] trackGroupArrays; - private static Uri testUri; + private static MediaItem testMediaItem; private DownloadHelper downloadHelper; @@ -114,7 +112,8 @@ public class DownloadHelperTest { trackGroupArrays = new TrackGroupArray[] {trackGroupArrayAll, trackGroupArraySingle}; - testUri = Uri.parse("http://test.uri"); + testMediaItem = + new MediaItem.Builder().setUri("http://test.uri").setCustomCacheKey("cacheKey").build(); } @Before @@ -128,9 +127,7 @@ public class DownloadHelperTest { downloadHelper = new DownloadHelper( - TEST_DOWNLOAD_TYPE, - testUri, - TEST_CACHE_KEY, + testMediaItem, new TestMediaSource(), DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, DownloadHelper.getRendererCapabilities(renderersFactory)); @@ -414,9 +411,10 @@ public class DownloadHelperTest { DownloadRequest downloadRequest = downloadHelper.getDownloadRequest(data); - assertThat(downloadRequest.type).isEqualTo(TEST_DOWNLOAD_TYPE); - assertThat(downloadRequest.uri).isEqualTo(testUri); - assertThat(downloadRequest.customCacheKey).isEqualTo(TEST_CACHE_KEY); + assertThat(downloadRequest.type).isEqualTo(DownloadRequest.TYPE_PROGRESSIVE); + assertThat(downloadRequest.uri).isEqualTo(testMediaItem.playbackProperties.uri); + assertThat(downloadRequest.customCacheKey) + .isEqualTo(testMediaItem.playbackProperties.customCacheKey); assertThat(downloadRequest.data).isEqualTo(data); assertThat(downloadRequest.streamKeys) .containsExactly( diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java index 5ecdba11eb..9efe20c635 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java @@ -15,13 +15,14 @@ */ package com.google.android.exoplayer2.source.dash.offline; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,16 +32,16 @@ public final class DownloadHelperTest { @Test public void staticDownloadHelperForDash_doesNotThrow() { - DownloadHelper.forDash( + DownloadHelper.forMediaItem( ApplicationProvider.getApplicationContext(), - Uri.parse("http://uri"), - new FakeDataSource.Factory(), - (handler, videoListener, audioListener, text, metadata) -> new Renderer[0]); - DownloadHelper.forDash( - Uri.parse("http://uri"), - new FakeDataSource.Factory(), + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_MPD).build(), (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], - /* drmSessionManager= */ DrmSessionManager.getDummyDrmSessionManager(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + new FakeDataSource.Factory()); + DownloadHelper.forMediaItem( + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_MPD).build(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], + new FakeDataSource.Factory(), + /* drmSessionManager= */ DrmSessionManager.getDummyDrmSessionManager()); } } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java index f1d0b8ab8a..834f0457b9 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java @@ -15,12 +15,13 @@ */ package com.google.android.exoplayer2.source.hls.offline; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,16 +31,22 @@ public final class DownloadHelperTest { @Test public void staticDownloadHelperForHls_doesNotThrow() { - DownloadHelper.forHls( + DownloadHelper.forMediaItem( ApplicationProvider.getApplicationContext(), - Uri.parse("http://uri"), - new FakeDataSource.Factory(), - (handler, videoListener, audioListener, text, metadata) -> new Renderer[0]); - DownloadHelper.forHls( - Uri.parse("http://uri"), - new FakeDataSource.Factory(), + new MediaItem.Builder() + .setUri("http://uri") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(), (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], - /* drmSessionManager= */ null, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + new FakeDataSource.Factory()); + DownloadHelper.forMediaItem( + new MediaItem.Builder() + .setUri("http://uri") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], + new FakeDataSource.Factory(), + /* drmSessionManager= */ null); } } diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java index b6d29d8b72..180deb3068 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java @@ -15,12 +15,13 @@ */ package com.google.android.exoplayer2.source.smoothstreaming.offline; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,16 +31,16 @@ public final class DownloadHelperTest { @Test public void staticDownloadHelperForSmoothStreaming_doesNotThrow() { - DownloadHelper.forSmoothStreaming( + DownloadHelper.forMediaItem( ApplicationProvider.getApplicationContext(), - Uri.parse("http://uri"), - new FakeDataSource.Factory(), - (handler, videoListener, audioListener, text, metadata) -> new Renderer[0]); - DownloadHelper.forSmoothStreaming( - Uri.parse("http://uri"), - new FakeDataSource.Factory(), + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_SS).build(), (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], - /* drmSessionManager= */ null, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + new FakeDataSource.Factory()); + DownloadHelper.forMediaItem( + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_SS).build(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], + new FakeDataSource.Factory(), + /* drmSessionManager= */ null); } } From f38a1015ae9cd54f42058c76f24f8a50de72c943 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 20 May 2020 14:32:52 +0100 Subject: [PATCH 0282/1052] Adding instructions on how to build and run ExoPlayer demo apps Issue:#7338 PiperOrigin-RevId: 312470913 --- demos/README.md | 21 +++++++++++++++++++++ demos/cast/README.md | 3 +++ demos/gl/README.md | 3 +++ demos/main/README.md | 3 +++ demos/surface/README.md | 3 +++ 5 files changed, 33 insertions(+) diff --git a/demos/README.md b/demos/README.md index 7e62249db1..2360e01137 100644 --- a/demos/README.md +++ b/demos/README.md @@ -2,3 +2,24 @@ This directory contains applications that demonstrate how to use ExoPlayer. Browse the individual demos and their READMEs to learn more. + +## Running a demo ## + +### From Android Studio ### + +* File -> New -> Import Project -> Specify the root ExoPlayer folder. +* Choose the demo from the run configuration dropdown list. +* Click Run. + +### Using gradle from the command line: ### + +* Open a Terminal window at the root ExoPlayer folder. +* Run `./gradlew projects` to show all projects. Demo projects start with `demo`. +* Run `./gradlew ::tasks` to view the list of available tasks for +the demo project. Choose an install option from the `Install tasks` section. +* Run `./gradlew ::`. + +**Example**: + +`./gradlew :demo:installNoExtensionsDebug` installs the main ExoPlayer demo app + in debug mode with no extensions. diff --git a/demos/cast/README.md b/demos/cast/README.md index 2c68a5277a..fd682433f9 100644 --- a/demos/cast/README.md +++ b/demos/cast/README.md @@ -2,3 +2,6 @@ This folder contains a demo application that showcases ExoPlayer integration with Google Cast. + +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. diff --git a/demos/gl/README.md b/demos/gl/README.md index 12dabe902b..9bffc3edea 100644 --- a/demos/gl/README.md +++ b/demos/gl/README.md @@ -8,4 +8,7 @@ drawn using an Android canvas, and includes the current frame's presentation timestamp, to show how to get the timestamp of the frame currently in the off-screen surface texture. +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. + [GLSurfaceView]: https://developer.android.com/reference/android/opengl/GLSurfaceView diff --git a/demos/main/README.md b/demos/main/README.md index bdb04e5ba8..00072c070b 100644 --- a/demos/main/README.md +++ b/demos/main/README.md @@ -3,3 +3,6 @@ This is the main ExoPlayer demo application. It uses ExoPlayer to play a number of test streams. It can be used as a starting point or reference project when developing other applications that make use of the ExoPlayer library. + +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. diff --git a/demos/surface/README.md b/demos/surface/README.md index 312259dbf6..3febb23feb 100644 --- a/demos/surface/README.md +++ b/demos/surface/README.md @@ -18,4 +18,7 @@ called, and because you can move output off-screen easily (`setOutputSurface` can't take a `null` surface, so the player has to use a `DummySurface`, which doesn't handle protected output on all devices). +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. + [SurfaceControl]: https://developer.android.com/reference/android/view/SurfaceControl From 02e74d6c94bd8bbe3d13acf4692195449c4df661 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 20 May 2020 15:39:50 +0100 Subject: [PATCH 0283/1052] Fix Guava deps from compileOnly to implementation PiperOrigin-RevId: 312479354 --- library/common/build.gradle | 2 +- library/core/build.gradle | 2 +- library/dash/build.gradle | 2 +- library/hls/build.gradle | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/library/common/build.gradle b/library/common/build.gradle index 03c5b9c4b6..14bfd6aba5 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -38,8 +38,8 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version - compileOnly 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/core/build.gradle b/library/core/build.gradle index a1d04dcf7b..70a2a92b2c 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -53,8 +53,8 @@ dependencies { api project(modulePrefix + 'library-common') api project(modulePrefix + 'library-extractor') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version - compileOnly 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/dash/build.gradle b/library/dash/build.gradle index 51fb08837a..33cbab1b90 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -40,7 +40,7 @@ android { dependencies { implementation project(modulePrefix + 'library-core') - compileOnly 'com.google.guava:guava:' + guavaVersion + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/hls/build.gradle b/library/hls/build.gradle index e6153d904f..4deef3b5f9 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -38,7 +38,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - compileOnly 'com.google.guava:guava:' + guavaVersion + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion From 9035f1d701b0eacaa7f8113428f84b23aba8b2d1 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Wed, 20 May 2020 15:39:59 +0100 Subject: [PATCH 0284/1052] Update getPcmEncoding return type to PcmEncoding rather than Encoding. PiperOrigin-RevId: 312479370 --- .../android/exoplayer2/audio/MediaCodecAudioRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index c69383ffe2..c98bd9bbb9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -839,7 +839,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media || Util.DEVICE.startsWith("ms01")); } - @C.Encoding + @C.PcmEncoding private static int getPcmEncoding(Format format) { // If the format is anything other than PCM then we assume that the audio decoder will output // 16-bit PCM. From 2397e7f67aa1b24e025391fc5ae38c9e5d68f3a4 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 20 May 2020 15:53:56 +0100 Subject: [PATCH 0285/1052] Replace TestUtil.joinByteArrays() with Guava's Bytes.concat() PiperOrigin-RevId: 312481058 --- .../emsg/EventMessageDecoderTest.java | 4 ++-- .../emsg/EventMessageEncoderTest.java | 6 +++--- .../mediacodec/BatchBufferTest.java | 3 ++- .../metadata/MetadataRendererTest.java | 7 ++++--- .../metadata/icy/IcyDecoderTest.java | 4 ++-- .../exoplayer2/source/SampleQueueTest.java | 3 ++- library/extractor/build.gradle | 1 + .../extractor/ogg/DefaultOggSeekerTest.java | 8 ++++---- .../extractor/ts/AdtsReaderTest.java | 5 +++-- .../exoplayer2/testutil/FakeTrackOutput.java | 5 +++-- .../android/exoplayer2/testutil/TestUtil.java | 20 ------------------- 11 files changed, 26 insertions(+), 40 deletions(-) diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java index ee2c55a735..c31c477e7c 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java @@ -17,13 +17,13 @@ package com.google.android.exoplayer2.metadata.emsg; import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; import static com.google.android.exoplayer2.testutil.TestUtil.createMetadataInputBuffer; -import static com.google.android.exoplayer2.testutil.TestUtil.joinByteArrays; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import com.google.common.primitives.Bytes; import org.junit.Test; import org.junit.runner.RunWith; @@ -34,7 +34,7 @@ public final class EventMessageDecoderTest { @Test public void decodeEventMessage() { byte[] rawEmsgBody = - joinByteArrays( + Bytes.concat( createByteArray(117, 114, 110, 58, 116, 101, 115, 116, 0), // scheme_id_uri = "urn:test" createByteArray(49, 50, 51, 0), // value = "123" createByteArray(0, 0, 11, 184), // event_duration_ms = 3000 diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java index fc73b0cdaf..6e18ac964c 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java @@ -17,12 +17,12 @@ package com.google.android.exoplayer2.metadata.emsg; import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; import static com.google.android.exoplayer2.testutil.TestUtil.createMetadataInputBuffer; -import static com.google.android.exoplayer2.testutil.TestUtil.joinByteArrays; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import com.google.common.primitives.Bytes; import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; @@ -35,7 +35,7 @@ public final class EventMessageEncoderTest { new EventMessage("urn:test", "123", 3000, 1000403, new byte[] {0, 1, 2, 3, 4}); private static final byte[] ENCODED_MESSAGE = - joinByteArrays( + Bytes.concat( createByteArray(117, 114, 110, 58, 116, 101, 115, 116, 0), // scheme_id_uri = "urn:test" createByteArray(49, 50, 51, 0), // value = "123" createByteArray(0, 0, 11, 184), // event_duration_ms = 3000 @@ -64,7 +64,7 @@ public final class EventMessageEncoderTest { EventMessage eventMessage1 = new EventMessage("urn:test", "123", 3000, 1000402, new byte[] {4, 3, 2, 1, 0}); byte[] expectedEmsgBody1 = - joinByteArrays( + Bytes.concat( createByteArray(117, 114, 110, 58, 116, 101, 115, 116, 0), // scheme_id_uri = "urn:test" createByteArray(49, 50, 51, 0), // value = "123" createByteArray(0, 0, 11, 184), // event_duration_ms = 3000 diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java index 4b36f1718c..909608e90c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java @@ -23,6 +23,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.common.primitives.Bytes; import java.nio.ByteBuffer; import org.junit.Test; import org.junit.runner.RunWith; @@ -153,7 +154,7 @@ public final class BatchBufferTest { batchBuffer.commitNextAccessUnit(); batchBuffer.flip(); - byte[] expected = TestUtil.joinByteArrays(TEST_ACCESS_UNIT, TEST_ACCESS_UNIT); + byte[] expected = Bytes.concat(TEST_ACCESS_UNIT, TEST_ACCESS_UNIT); assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(expected)); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java index cf57f15f2f..1d1cb20b34 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java @@ -32,6 +32,7 @@ import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamI import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.primitives.Bytes; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -43,7 +44,7 @@ import org.junit.runner.RunWith; public class MetadataRendererTest { private static final byte[] SCTE35_TIME_SIGNAL_BYTES = - TestUtil.joinByteArrays( + Bytes.concat( TestUtil.createByteArray( 0, // table_id. 0x80, // section_syntax_indicator, private_indicator, reserved, section_length(4). @@ -170,7 +171,7 @@ public class MetadataRendererTest { */ private static byte[] encodeTxxxId3Frame(String description, String value) { byte[] id3FrameData = - TestUtil.joinByteArrays( + Bytes.concat( "TXXX".getBytes(ISO_8859_1), // ID for a 'user defined text information frame' TestUtil.createByteArray(0, 0, 0, 0), // Frame size (set later) TestUtil.createByteArray(0, 0), // Frame flags @@ -186,7 +187,7 @@ public class MetadataRendererTest { id3FrameData[frameSizeIndex] = (byte) frameSize; byte[] id3Bytes = - TestUtil.joinByteArrays( + Bytes.concat( "ID3".getBytes(ISO_8859_1), // identifier TestUtil.createByteArray(0x04, 0x00), // version TestUtil.createByteArray(0), // Tag flags diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java index 49cca0367d..d16941b021 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java @@ -26,7 +26,7 @@ import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.common.primitives.Bytes; import org.junit.Test; import org.junit.runner.RunWith; @@ -54,7 +54,7 @@ public final class IcyDecoderTest { public void decode_respectsLimit() { byte[] icyTitle = "StreamTitle='test title';".getBytes(UTF_8); byte[] icyUrl = "StreamURL='test_url';".getBytes(UTF_8); - byte[] paddedRawBytes = TestUtil.joinByteArrays(icyTitle, icyUrl); + byte[] paddedRawBytes = Bytes.concat(icyTitle, icyUrl); MetadataInputBuffer metadataBuffer = createMetadataInputBuffer(paddedRawBytes); // Stop before the stream URL. metadataBuffer.data.limit(icyTitle.length); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 41b953a0d2..480735a689 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -43,6 +43,7 @@ import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.primitives.Bytes; import java.io.IOException; import java.util.Arrays; import java.util.concurrent.atomic.AtomicReference; @@ -483,7 +484,7 @@ public final class SampleQueueTest { byte[] sampleData = new byte[] {0, 1, 2}; byte[] initializationVector = new byte[] {7, 6, 5, 4, 3, 2, 1, 0}; byte[] encryptedSampleData = - TestUtil.joinByteArrays( + Bytes.concat( new byte[] { 0x08, // subsampleEncryption = false (1 bit), ivSize = 8 (7 bits). }, diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index e12eb009eb..d9a5128b13 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -41,6 +41,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'library-common') + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java index 83aa8c6d9b..be471ac40c 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.primitives.Bytes; import java.io.EOFException; import java.io.IOException; import java.util.Random; @@ -125,7 +126,7 @@ public final class DefaultOggSeekerTest { public void skipToNextPage_success() throws Exception { FakeExtractorInput extractorInput = createInput( - TestUtil.joinByteArrays( + Bytes.concat( TestUtil.buildTestData(4000, random), new byte[] {'O', 'g', 'g', 'S'}, TestUtil.buildTestData(4000, random)), @@ -138,7 +139,7 @@ public final class DefaultOggSeekerTest { public void skipToNextPage_withOverlappingInput_success() throws Exception { FakeExtractorInput extractorInput = createInput( - TestUtil.joinByteArrays( + Bytes.concat( TestUtil.buildTestData(2046, random), new byte[] {'O', 'g', 'g', 'S'}, TestUtil.buildTestData(4000, random)), @@ -151,8 +152,7 @@ public final class DefaultOggSeekerTest { public void skipToNextPage_withInputShorterThanPeekLength_success() throws Exception { FakeExtractorInput extractorInput = createInput( - TestUtil.joinByteArrays(new byte[] {'x', 'O', 'g', 'g', 'S'}), - /* simulateUnknownLength= */ false); + Bytes.concat(new byte[] {'x', 'O', 'g', 'g', 'S'}), /* simulateUnknownLength= */ false); skipToNextPage(extractorInput); assertThat(extractorInput.getPosition()).isEqualTo(1); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java index c04c7224f9..a30fb3983b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.testutil.FakeExtractorOutput; import com.google.android.exoplayer2.testutil.FakeTrackOutput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.primitives.Bytes; import java.util.Arrays; import org.junit.Before; import org.junit.Test; @@ -57,7 +58,7 @@ public class AdtsReaderTest { TestUtil.createByteArray(0x20, 0x00, 0x20, 0x00, 0x00, 0x80, 0x0e); private static final byte[] TEST_DATA = - TestUtil.joinByteArrays(ID3_DATA_1, ID3_DATA_2, ADTS_HEADER, ADTS_CONTENT); + Bytes.concat(ID3_DATA_1, ID3_DATA_2, ADTS_HEADER, ADTS_CONTENT); private static final long ADTS_SAMPLE_DURATION = 23219L; @@ -94,7 +95,7 @@ public class AdtsReaderTest { public void skipToNextSampleResetsState() throws Exception { data = new ParsableByteArray( - TestUtil.joinByteArrays( + Bytes.concat( ADTS_HEADER, ADTS_CONTENT, ADTS_HEADER, diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java index 2bdf19fec2..236eef0b60 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Function; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Bytes; import java.io.EOFException; import java.io.IOException; import java.util.ArrayList; @@ -105,7 +106,7 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { throw new EOFException(); } newData = Arrays.copyOf(newData, bytesAppended); - sampleData = TestUtil.joinByteArrays(sampleData, newData); + sampleData = Bytes.concat(sampleData, newData); return bytesAppended; } @@ -113,7 +114,7 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) { byte[] newData = new byte[length]; data.readBytes(newData, 0, length); - sampleData = TestUtil.joinByteArrays(sampleData, newData); + sampleData = Bytes.concat(sampleData, newData); } @Override diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 4e7b71aa41..0aac047e44 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -168,26 +168,6 @@ public class TestUtil { return byteArray; } - /** - * Concatenates the provided byte arrays. - * - * @param byteArrays The byte arrays to concatenate. - * @return The concatenated result. - */ - public static byte[] joinByteArrays(byte[]... byteArrays) { - int length = 0; - for (byte[] byteArray : byteArrays) { - length += byteArray.length; - } - byte[] joined = new byte[length]; - length = 0; - for (byte[] byteArray : byteArrays) { - System.arraycopy(byteArray, 0, joined, length, byteArray.length); - length += byteArray.length; - } - return joined; - } - /** Writes one byte long dummy test data to the file and returns it. */ public static File createTestFile(File directory, String name) throws IOException { return createTestFile(directory, name, /* length= */ 1); From 03d9375872e24ab31b296541c2ea953b76ecef0e Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 20 May 2020 22:21:29 +0100 Subject: [PATCH 0286/1052] Fix demo app persistent sample selection It currently crashes if the samples change such that the persisted position is no longer within bounds. PiperOrigin-RevId: 312554337 --- .../demo/SampleChooserActivity.java | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 32c55b8a5f..bd340b7436 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -71,8 +71,8 @@ public class SampleChooserActivity extends AppCompatActivity implements DownloadTracker.Listener, OnChildClickListener { private static final String TAG = "SampleChooserActivity"; - private static final String GROUP_POSITION_PREFERENCE_KEY = "SAMPLE_CHOOSER_GROUP_POSITION"; - private static final String CHILD_POSITION_PREFERENCE_KEY = "SAMPLE_CHOOSER_CHILD_POSITION"; + private static final String GROUP_POSITION_PREFERENCE_KEY = "sample_chooser_group_position"; + private static final String CHILD_POSITION_PREFERENCE_KEY = "sample_chooser_child_position"; private String[] uris; private boolean useExtensionRenderers; @@ -209,16 +209,13 @@ public class SampleChooserActivity extends AppCompatActivity sampleAdapter.setPlaylistGroups(groups); SharedPreferences preferences = getPreferences(MODE_PRIVATE); - - int groupPosition = -1; - int childPosition = -1; - try { - groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1); - childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1); - } catch (ClassCastException e) { - Log.w(TAG, "Saved position is not an int. Will not restore position.", e); - } - if (groupPosition != -1 && childPosition != -1) { + int groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + int childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + // Clear the group and child position if either are unset or if either are out of bounds. + if (groupPosition != -1 + && childPosition != -1 + && groupPosition < groups.size() + && childPosition < groups.get(groupPosition).playlists.size()) { sampleListView.expandGroup(groupPosition); // shouldExpandGroup does not work without this. sampleListView.setSelectedChild(groupPosition, childPosition, /* shouldExpandGroup= */ true); } From 63522ea554e96dc45091956956708301562872ed Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 21 May 2020 10:59:26 +0100 Subject: [PATCH 0287/1052] Improve CacheKeyFactory documentation - To make it clear that cache keys are for whole resources - To explicitly make it clear to implementors that deriving a cache key from position and length is invalid. We've seen at least one developer trying to do this [1], so it seems worthwhile to be explicit! [1] https://github.com/google/ExoPlayer/issues/5978#issuecomment-618977036 Issue: #5978 PiperOrigin-RevId: 312643930 --- .../exoplayer2/upstream/cache/CacheKeyFactory.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java index 2236b5f9cc..69e9b73fdd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java @@ -25,10 +25,15 @@ public interface CacheKeyFactory { (dataSpec) -> dataSpec.key != null ? dataSpec.key : dataSpec.uri.toString(); /** - * Returns a cache key for the given {@link DataSpec}. + * Returns the cache key of the resource containing the data defined by a {@link DataSpec}. * - * @param dataSpec The data being cached. - * @return The cache key. + *

    Note that since the returned cache key corresponds to the whole resource, implementations + * must not return different cache keys for {@link DataSpec DataSpecs} that define different + * ranges of the same resource. As a result, implementations should not use fields such as {@link + * DataSpec#position} and {@link DataSpec#length}. + * + * @param dataSpec The {@link DataSpec}. + * @return The cache key of the resource. */ String buildCacheKey(DataSpec dataSpec); } From 634634f8e377774a4a8b9aaee52b892fecb04547 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 21 May 2020 11:29:07 +0100 Subject: [PATCH 0288/1052] Make DashMediaSource add the media item to the timeline PiperOrigin-RevId: 312646461 --- .../source/dash/DashMediaSource.java | 163 +++++++----------- .../source/dash/DashMediaSourceTest.java | 108 ++++++++++++ 2 files changed, 174 insertions(+), 97 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 4b74956816..8b69500c94 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.source.dash; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.net.Uri; import android.os.Handler; import android.os.SystemClock; @@ -58,6 +61,7 @@ import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.SntpClient; import com.google.android.exoplayer2.util.Util; import java.io.BufferedReader; @@ -118,7 +122,7 @@ public final class DashMediaSource extends BaseMediaSource { public Factory( DashChunkSource.Factory chunkSourceFactory, @Nullable DataSource.Factory manifestDataSourceFactory) { - this.chunkSourceFactory = Assertions.checkNotNull(chunkSourceFactory); + this.chunkSourceFactory = checkNotNull(chunkSourceFactory); this.manifestDataSourceFactory = manifestDataSourceFactory; drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); @@ -265,8 +269,14 @@ public final class DashMediaSource extends BaseMediaSource { manifest = manifest.copy(streamKeys); } return new DashMediaSource( + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMediaId(DUMMY_MEDIA_ID) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setStreamKeys(streamKeys) + .setTag(tag) + .build(), manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, @@ -274,8 +284,7 @@ public final class DashMediaSource extends BaseMediaSource { drmSessionManager, loadErrorHandlingPolicy, livePresentationDelayMs, - livePresentationDelayOverridesManifest, - tag); + livePresentationDelayOverridesManifest); } /** @@ -298,6 +307,7 @@ public final class DashMediaSource extends BaseMediaSource { * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ + @SuppressWarnings("deprecation") @Deprecated public DashMediaSource createMediaSource( Uri manifestUri, @@ -315,7 +325,12 @@ public final class DashMediaSource extends BaseMediaSource { @Deprecated @Override public DashMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setUri(uri).build()); + return createMediaSource( + new MediaItem.Builder() + .setUri(uri) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(tag) + .build()); } /** @@ -327,7 +342,7 @@ public final class DashMediaSource extends BaseMediaSource { */ @Override public DashMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); @Nullable ParsingLoadable.Parser manifestParser = this.manifestParser; if (manifestParser == null) { manifestParser = new DashManifestParser(); @@ -339,9 +354,14 @@ public final class DashMediaSource extends BaseMediaSource { if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } + if (mediaItem.playbackProperties.tag == null && tag != null) { + mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); + } else if (mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty()) { + mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); + } return new DashMediaSource( + mediaItem, /* manifest= */ null, - mediaItem.playbackProperties.uri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, @@ -349,8 +369,7 @@ public final class DashMediaSource extends BaseMediaSource { drmSessionManager, loadErrorHandlingPolicy, livePresentationDelayMs, - livePresentationDelayOverridesManifest, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + livePresentationDelayOverridesManifest); } @Override @@ -371,6 +390,10 @@ public final class DashMediaSource extends BaseMediaSource { /** @deprecated Use of this parameter is no longer necessary. */ @Deprecated public static final long DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS = -1; + /** The media id used by media items of dash media sources without a manifest URI. */ + public static final String DUMMY_MEDIA_ID = + "com.google.android.exoplayer2.source.dash.DashMediaSource"; + /** * The interval in milliseconds between invocations of {@link * MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} when the source's {@link @@ -401,7 +424,6 @@ public final class DashMediaSource extends BaseMediaSource { private final Runnable simulateManifestRefreshRunnable; private final PlayerEmsgCallback playerEmsgCallback; private final LoaderErrorThrower manifestLoadErrorThrower; - @Nullable private final Object tag; private DataSource dataSource; private Loader loader; @@ -410,7 +432,8 @@ public final class DashMediaSource extends BaseMediaSource { private IOException manifestFatalError; private Handler handler; - private Uri initialManifestUri; + private MediaItem mediaItem; + private MediaItem.PlaybackProperties playbackProperties; private Uri manifestUri; private DashManifest manifest; private boolean manifestLoadPending; @@ -423,15 +446,7 @@ public final class DashMediaSource extends BaseMediaSource { private int firstPeriodId; - /** - * Constructs an instance to play a given {@link DashManifest}, which must be static. - * - * @param manifest The manifest. {@link DashManifest#dynamic} must be false. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -447,16 +462,7 @@ public final class DashMediaSource extends BaseMediaSource { eventListener); } - /** - * Constructs an instance to play a given {@link DashManifest}, which must be static. - * - * @param manifest The manifest. {@link DashManifest#dynamic} must be false. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated public DashMediaSource( DashManifest manifest, @@ -465,8 +471,12 @@ public final class DashMediaSource extends BaseMediaSource { @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder() + .setMediaId(DUMMY_MEDIA_ID) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setUri(Uri.EMPTY) + .build(), manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, @@ -474,25 +484,13 @@ public final class DashMediaSource extends BaseMediaSource { DrmSessionManager.getDummyDrmSessionManager(), new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), DEFAULT_LIVE_PRESENTATION_DELAY_MS, - /* livePresentationDelayOverridesManifest= */ false, - /* tag= */ null); + /* livePresentationDelayOverridesManifest= */ false); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } } - /** - * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or - * static. - * - * @param manifestUri The manifest {@link Uri}. - * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used - * to load (and refresh) the manifest. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -511,23 +509,7 @@ public final class DashMediaSource extends BaseMediaSource { eventListener); } - /** - * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or - * static. - * - * @param manifestUri The manifest {@link Uri}. - * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used - * to load (and refresh) the manifest. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the - * default start position should precede the end of the live window. Use {@link - * #DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS} to use the value specified by the - * manifest, if present. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -549,24 +531,7 @@ public final class DashMediaSource extends BaseMediaSource { eventListener); } - /** - * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or - * static. - * - * @param manifestUri The manifest {@link Uri}. - * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used - * to load (and refresh) the manifest. - * @param manifestParser A parser for loaded manifest data. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the - * default start position should precede the end of the live window. Use {@link - * #DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS} to use the value specified by the - * manifest, if present. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -579,8 +544,8 @@ public final class DashMediaSource extends BaseMediaSource { @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(manifestUri).setMimeType(MimeTypes.APPLICATION_MPD).build(), /* manifest= */ null, - manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, @@ -590,16 +555,15 @@ public final class DashMediaSource extends BaseMediaSource { livePresentationDelayMs == DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS ? DEFAULT_LIVE_PRESENTATION_DELAY_MS : livePresentationDelayMs, - livePresentationDelayMs != DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS, - /* tag= */ null); + livePresentationDelayMs != DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } } private DashMediaSource( + MediaItem mediaItem, @Nullable DashManifest manifest, - @Nullable Uri manifestUri, @Nullable DataSource.Factory manifestDataSourceFactory, @Nullable ParsingLoadable.Parser manifestParser, DashChunkSource.Factory chunkSourceFactory, @@ -607,11 +571,11 @@ public final class DashMediaSource extends BaseMediaSource { DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, long livePresentationDelayMs, - boolean livePresentationDelayOverridesManifest, - @Nullable Object tag) { - this.initialManifestUri = manifestUri; + boolean livePresentationDelayOverridesManifest) { + this.mediaItem = mediaItem; + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); + this.manifestUri = playbackProperties.uri; this.manifest = manifest; - this.manifestUri = manifestUri; this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; @@ -620,7 +584,6 @@ public final class DashMediaSource extends BaseMediaSource { this.livePresentationDelayMs = livePresentationDelayMs; this.livePresentationDelayOverridesManifest = livePresentationDelayOverridesManifest; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; - this.tag = tag; sideloadedManifest = manifest != null; manifestEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); manifestUriLock = new Object(); @@ -650,7 +613,8 @@ public final class DashMediaSource extends BaseMediaSource { public void replaceManifestUri(Uri manifestUri) { synchronized (manifestUriLock) { this.manifestUri = manifestUri; - this.initialManifestUri = manifestUri; + this.mediaItem = mediaItem.buildUpon().setUri(manifestUri).build(); + this.playbackProperties = castNonNull(mediaItem.playbackProperties); } } @@ -659,7 +623,12 @@ public final class DashMediaSource extends BaseMediaSource { @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -724,7 +693,7 @@ public final class DashMediaSource extends BaseMediaSource { manifestLoadStartTimestampMs = 0; manifestLoadEndTimestampMs = 0; manifest = sideloadedManifest ? manifest : null; - manifestUri = initialManifestUri; + manifestUri = playbackProperties.uri; manifestFatalError = null; if (handler != null) { handler.removeCallbacksAndMessages(null); @@ -1079,7 +1048,7 @@ public final class DashMediaSource extends BaseMediaSource { windowDurationUs, windowDefaultStartPositionUs, manifest, - tag); + mediaItem); refreshSourceInfo(timeline); if (!sideloadedManifest) { @@ -1223,7 +1192,7 @@ public final class DashMediaSource extends BaseMediaSource { private final long windowDurationUs; private final long windowDefaultStartPositionUs; private final DashManifest manifest; - @Nullable private final Object windowTag; + private final MediaItem mediaItem; public DashTimeline( long presentationStartTimeMs, @@ -1234,7 +1203,7 @@ public final class DashMediaSource extends BaseMediaSource { long windowDurationUs, long windowDefaultStartPositionUs, DashManifest manifest, - @Nullable Object windowTag) { + MediaItem mediaItem) { this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs; @@ -1243,7 +1212,7 @@ public final class DashMediaSource extends BaseMediaSource { this.windowDurationUs = windowDurationUs; this.windowDefaultStartPositionUs = windowDefaultStartPositionUs; this.manifest = manifest; - this.windowTag = windowTag; + this.mediaItem = mediaItem; } @Override @@ -1273,7 +1242,7 @@ public final class DashMediaSource extends BaseMediaSource { defaultPositionProjectionUs); return window.set( Window.SINGLE_WINDOW_UID, - windowTag, + mediaItem, manifest, presentationStartTimeMs, windowStartTimeMs, diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java index 3c8952fd62..9771d09370 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java @@ -17,11 +17,17 @@ package com.google.android.exoplayer2.source.dash; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; import org.junit.Test; @@ -68,6 +74,108 @@ public final class DashMediaSourceTest { } } + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { + Object factoryTag = new Object(); + Object mediaItemTag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(factoryTag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(new Object()); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKeys() { + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(mock(DataSource.Factory.class)) + .setStreamKeys(ImmutableList.of(streamKey)); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotsOverrideMediaItemStreamKeys() { + StreamKey mediaItemStreamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://www.google.com") + .setStreamKeys(ImmutableList.of(mediaItemStreamKey)) + .build(); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(mock(DataSource.Factory.class)) + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + } + private static void assertParseStringToLong( long expected, ParsingLoadable.Parser parser, String data) throws IOException { long actual = parser.parse(null, new ByteArrayInputStream(Util.getUtf8Bytes(data))); From 1154e8098ab079f5faf9c2cd1decc2339468c118 Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 21 May 2020 11:44:36 +0100 Subject: [PATCH 0289/1052] Rollback of https://github.com/google/ExoPlayer/commit/78c850a88579beb5ebf2c487a0bdd45a97d0c881 *** Original commit *** Remove set timeout on release() and setSurface() Removes the experimental methods to set a timeout when releasing the player and setting the surface. *** PiperOrigin-RevId: 312647457 --- .../google/android/exoplayer2/ExoPlayer.java | 20 +++ .../android/exoplayer2/ExoPlayerImpl.java | 23 +++- .../exoplayer2/ExoPlayerImplInternal.java | 85 ++++++++++-- .../android/exoplayer2/PlayerMessage.java | 45 +++++++ .../android/exoplayer2/PlayerMessageTest.java | 125 ++++++++++++++++++ 5 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 89a28bd764..b4cd9a399d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -149,6 +149,8 @@ public interface ExoPlayer extends Player { private SeekParameters seekParameters; private boolean pauseAtEndOfMediaItems; private boolean buildCalled; + + private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; /** @@ -213,6 +215,20 @@ public interface ExoPlayer extends Player { clock = Clock.DEFAULT; } + /** + * Set a limit on the time a call to {@link ExoPlayer#release()} can spend. If a call to {@link + * ExoPlayer#release()} takes more than {@code timeoutMs} milliseconds to complete, the player + * will raise an error via {@link Player.EventListener#onPlayerError}. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + * @param timeoutMs The time limit in milliseconds, or 0 for no limit. + */ + public Builder experimental_setReleaseTimeoutMs(long timeoutMs) { + releaseTimeoutMs = timeoutMs; + return this; + } + /** * Sets whether the player should throw when it detects it's stuck buffering. * @@ -389,6 +405,10 @@ public interface ExoPlayer extends Player { pauseAtEndOfMediaItems, clock, looper); + + if (releaseTimeoutMs > 0) { + player.experimental_setReleaseTimeoutMs(releaseTimeoutMs); + } if (throwWhenStuckBuffering) { player.experimental_throwWhenStuckBuffering(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index e98da39a10..26357a18dc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -45,6 +45,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeoutException; /** * An {@link ExoPlayer} implementation. Instances can be obtained from {@link ExoPlayer.Builder}. @@ -177,6 +178,20 @@ import java.util.concurrent.CopyOnWriteArrayList; internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); } + /** + * Set a limit on the time a call to {@link #release()} can spend. If a call to {@link #release()} + * takes more than {@code timeoutMs} milliseconds to complete, the player will raise an error via + * {@link Player.EventListener#onPlayerError}. + * + *

    This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the player is used. + * + * @param timeoutMs The time limit in milliseconds, or 0 for no limit. + */ + public void experimental_setReleaseTimeoutMs(long timeoutMs) { + internalPlayer.experimental_setReleaseTimeoutMs(timeoutMs); + } + /** * Configures the player to throw when it detects it's stuck buffering. * @@ -675,7 +690,13 @@ import java.util.concurrent.CopyOnWriteArrayList; Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + ExoPlayerLibraryInfo.registeredModules() + "]"); - internalPlayer.release(); + if (!internalPlayer.release()) { + notifyListeners( + listener -> + listener.onPlayerError( + ExoPlaybackException.createForUnexpected( + new RuntimeException(new TimeoutException("Player release timed out."))))); + } applicationHandler.removeCallbacksAndMessages(null); playbackInfo = getResetPlaybackInfo( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 5d698b8f66..53c8a5d080 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -133,6 +133,8 @@ import java.util.concurrent.atomic.AtomicBoolean; private long rendererPositionUs; private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; + + private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; public ExoPlayerImplInternal( @@ -189,6 +191,10 @@ import java.util.concurrent.atomic.AtomicBoolean; } } + public void experimental_setReleaseTimeoutMs(long releaseTimeoutMs) { + this.releaseTimeoutMs = releaseTimeoutMs; + } + public void experimental_throwWhenStuckBuffering() { throwWhenStuckBuffering = true; } @@ -316,23 +322,23 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - public synchronized void release() { + public synchronized boolean release() { if (released || !internalPlaybackThread.isAlive()) { - return; + return true; } + handler.sendEmptyMessage(MSG_RELEASE); - boolean wasInterrupted = false; - while (!released) { - try { - wait(); - } catch (InterruptedException e) { - wasInterrupted = true; + try { + if (releaseTimeoutMs > 0) { + waitUntilReleased(releaseTimeoutMs); + } else { + waitUntilReleased(); } - } - if (wasInterrupted) { - // Restore the interrupted status. + } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + + return released; } public Looper getPlaybackLooper() { @@ -498,6 +504,63 @@ import java.util.concurrent.atomic.AtomicBoolean; // Private methods. + /** + * Blocks the current thread until {@link #releaseInternal()} is executed on the playback Thread. + * + *

    If the current thread is interrupted while waiting for {@link #releaseInternal()} to + * complete, this method will delay throwing the {@link InterruptedException} to ensure that the + * underlying resources have been released, and will an {@link InterruptedException} after + * {@link #releaseInternal()} is complete. + * + * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for + * {@link #releaseInternal()} to complete. + */ + private synchronized void waitUntilReleased() throws InterruptedException { + InterruptedException interruptedException = null; + while (!released) { + try { + wait(); + } catch (InterruptedException e) { + interruptedException = e; + } + } + + if (interruptedException != null) { + throw interruptedException; + } + } + + /** + * Blocks the current thread until {@link #releaseInternal()} is performed on the playback Thread + * or the specified amount of time has elapsed. + * + *

    If the current thread is interrupted while waiting for {@link #releaseInternal()} to + * complete, this method will delay throwing the {@link InterruptedException} to ensure that the + * underlying resources have been released or the operation timed out, and will throw an {@link + * InterruptedException} afterwards. + * + * @param timeoutMs the time in milliseconds to wait for {@link #releaseInternal()} to complete. + * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for + * {@link #releaseInternal()} to complete. + */ + private synchronized void waitUntilReleased(long timeoutMs) throws InterruptedException { + long deadlineMs = clock.elapsedRealtime() + timeoutMs; + long remainingMs = timeoutMs; + InterruptedException interruptedException = null; + while (!released && remainingMs > 0) { + try { + wait(remainingMs); + } catch (InterruptedException e) { + interruptedException = e; + } + remainingMs = deadlineMs - clock.elapsedRealtime(); + } + + if (interruptedException != null) { + throw interruptedException; + } + } + private void setState(int state) { if (playbackInfo.playbackState != state) { playbackInfo = playbackInfo.copyWithPlaybackState(state); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java index 9837cb59da..be7c7ce973 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java @@ -17,7 +17,10 @@ package com.google.android.exoplayer2; import android.os.Handler; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Clock; +import java.util.concurrent.TimeoutException; /** * Defines a player message which can be sent with a {@link Sender} and received by a {@link @@ -289,6 +292,28 @@ public final class PlayerMessage { return isDelivered; } + /** + * Blocks until after the message has been delivered or the player is no longer able to deliver + * the message or the specified waiting time elapses. + * + *

    Note that this method can't be called if the current thread is the same thread used by the + * message handler set with {@link #setHandler(Handler)} as it would cause a deadlock. + * + * @param timeoutMs the maximum time to wait in milliseconds. + * @return Whether the message was delivered successfully. + * @throws IllegalStateException If this method is called before {@link #send()}. + * @throws IllegalStateException If this method is called on the same thread used by the message + * handler set with {@link #setHandler(Handler)}. + * @throws TimeoutException If the waiting time elapsed and this message has not been delivered + * and the player is still able to deliver the message. + * @throws InterruptedException If the current thread is interrupted while waiting for the message + * to be delivered. + */ + public synchronized boolean experimental_blockUntilDelivered(long timeoutMs) + throws InterruptedException, TimeoutException { + return experimental_blockUntilDelivered(timeoutMs, Clock.DEFAULT); + } + /** * Marks the message as processed. Should only be called by a {@link Sender} and may be called * multiple times. @@ -302,4 +327,24 @@ public final class PlayerMessage { isProcessed = true; notifyAll(); } + + @VisibleForTesting() + /* package */ synchronized boolean experimental_blockUntilDelivered(long timeoutMs, Clock clock) + throws InterruptedException, TimeoutException { + Assertions.checkState(isSent); + Assertions.checkState(handler.getLooper().getThread() != Thread.currentThread()); + + long deadlineMs = clock.elapsedRealtime() + timeoutMs; + long remainingMs = timeoutMs; + while (!isProcessed && remainingMs > 0) { + wait(remainingMs); + remainingMs = deadlineMs - clock.elapsedRealtime(); + } + + if (!isProcessed) { + throw new TimeoutException("Message delivery timed out."); + } + + return isDelivered; + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java new file mode 100644 index 0000000000..874a8c5a5a --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.os.Handler; +import android.os.HandlerThread; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.Clock; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; + +/** Unit test for {@link PlayerMessage}. */ +@RunWith(AndroidJUnit4.class) +public class PlayerMessageTest { + + private static final long TIMEOUT_MS = 10; + + @Mock Clock clock; + private HandlerThread handlerThread; + private PlayerMessage message; + + @Before + public void setUp() { + initMocks(this); + PlayerMessage.Sender sender = (message) -> {}; + PlayerMessage.Target target = (messageType, payload) -> {}; + handlerThread = new HandlerThread("TestHandler"); + handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); + message = + new PlayerMessage(sender, target, Timeline.EMPTY, /* defaultWindowIndex= */ 0, handler); + } + + @After + public void tearDown() { + handlerThread.quit(); + } + + @Test + public void experimental_blockUntilDelivered_timesOut() throws Exception { + when(clock.elapsedRealtime()).thenReturn(0L).thenReturn(TIMEOUT_MS * 2); + + try { + message.send().experimental_blockUntilDelivered(TIMEOUT_MS, clock); + fail(); + } catch (TimeoutException expected) { + } + + // Ensure experimental_blockUntilDelivered() entered the blocking loop + verify(clock, Mockito.times(2)).elapsedRealtime(); + } + + @Test + public void experimental_blockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { + when(clock.elapsedRealtime()).thenReturn(0L); + + message.send().markAsProcessed(/* isDelivered= */ true); + + assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + } + + @Test + public void experimental_blockUntilDelivered_markAsProcessedWhileBlocked_succeeds() + throws Exception { + message.send(); + + // Use a separate Thread to mark the message as processed. + CountDownLatch prepareLatch = new CountDownLatch(1); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + Future future = + executorService.submit( + () -> { + prepareLatch.await(); + message.markAsProcessed(true); + return true; + }); + + when(clock.elapsedRealtime()) + .thenReturn(0L) + .then( + (invocation) -> { + // Signal the background thread to call PlayerMessage#markAsProcessed. + prepareLatch.countDown(); + return TIMEOUT_MS - 1; + }); + + try { + assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + // Ensure experimental_blockUntilDelivered() entered the blocking loop. + verify(clock, Mockito.atLeast(2)).elapsedRealtime(); + future.get(1, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + } +} From 4384ef5b738d4c1ecc039fa05127641f4b9ad6ef Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 21 May 2020 12:00:56 +0100 Subject: [PATCH 0290/1052] Make CacheUtil only about writing A subsequent change will rename it to CacheWriter and make it instantiable. Issue: #5978 PiperOrigin-RevId: 312648623 --- .../exoplayer2/offline/SegmentDownloader.java | 19 +++-- .../exoplayer2/upstream/cache/CacheUtil.java | 69 ++++--------------- .../upstream/cache/CacheUtilTest.java | 60 ---------------- 3 files changed, 25 insertions(+), 123 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 3358cc02c8..02337248e1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.offline; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import android.net.Uri; -import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; @@ -30,6 +29,7 @@ import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.ContentMetadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.Util; @@ -136,11 +136,18 @@ public abstract class SegmentDownloader> impleme long contentLength = 0; long bytesDownloaded = 0; for (int i = segments.size() - 1; i >= 0; i--) { - Segment segment = segments.get(i); - Pair segmentLengthAndBytesDownloaded = - CacheUtil.getCached(segment.dataSpec, cache, cacheKeyFactory); - long segmentLength = segmentLengthAndBytesDownloaded.first; - long segmentBytesDownloaded = segmentLengthAndBytesDownloaded.second; + DataSpec dataSpec = segments.get(i).dataSpec; + String cacheKey = cacheKeyFactory.buildCacheKey(dataSpec); + long segmentLength = dataSpec.length; + if (segmentLength == C.LENGTH_UNSET) { + long resourceLength = + ContentMetadata.getContentLength(cache.getContentMetadata(cacheKey)); + if (resourceLength != C.LENGTH_UNSET) { + segmentLength = resourceLength - dataSpec.position; + } + } + long segmentBytesDownloaded = + cache.getCachedBytes(cacheKey, dataSpec.position, segmentLength); bytesDownloaded += segmentBytesDownloaded; if (segmentLength != C.LENGTH_UNSET) { if (segmentLength == segmentBytesDownloaded) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index 57c9ecbf55..1e850df278 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.upstream.cache; -import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.google.android.exoplayer2.C; @@ -57,38 +56,6 @@ public final class CacheUtil { @Deprecated public static final CacheKeyFactory DEFAULT_CACHE_KEY_FACTORY = CacheKeyFactory.DEFAULT; - /** - * Queries the cache to obtain the request length and the number of bytes already cached for a - * given {@link DataSpec}. - * - * @param dataSpec Defines the data to be checked. - * @param cache A {@link Cache} which has the data. - * @param cacheKeyFactory An optional factory for cache keys. - * @return A pair containing the request length and the number of bytes that are already cached. - */ - public static Pair getCached( - DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) { - String key = buildCacheKey(dataSpec, cacheKeyFactory); - long position = dataSpec.position; - long requestLength = getRequestLength(dataSpec, cache, key); - long bytesAlreadyCached = 0; - long bytesLeft = requestLength; - while (bytesLeft != 0) { - long blockLength = cache.getCachedLength(key, position, bytesLeft); - if (blockLength > 0) { - bytesAlreadyCached += blockLength; - } else { - blockLength = -blockLength; - if (blockLength == Long.MAX_VALUE) { - break; - } - } - position += blockLength; - bytesLeft -= bytesLeft == C.LENGTH_UNSET ? 0 : blockLength; - } - return Pair.create(requestLength, bytesAlreadyCached); - } - /** * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early * if the end of the input is reached. @@ -157,23 +124,26 @@ public final class CacheUtil { Assertions.checkNotNull(temporaryBuffer); Cache cache = dataSource.getCache(); - CacheKeyFactory cacheKeyFactory = dataSource.getCacheKeyFactory(); - String key = buildCacheKey(dataSpec, cacheKeyFactory); - long bytesLeft; + String cacheKey = dataSource.getCacheKeyFactory().buildCacheKey(dataSpec); + long requestLength = dataSpec.length; + if (requestLength == C.LENGTH_UNSET) { + long resourceLength = ContentMetadata.getContentLength(cache.getContentMetadata(cacheKey)); + if (resourceLength != C.LENGTH_UNSET) { + requestLength = resourceLength - dataSpec.position; + } + } + long bytesCached = cache.getCachedBytes(cacheKey, dataSpec.position, requestLength); @Nullable ProgressNotifier progressNotifier = null; if (progressListener != null) { progressNotifier = new ProgressNotifier(progressListener); - Pair lengthAndBytesAlreadyCached = getCached(dataSpec, cache, cacheKeyFactory); - progressNotifier.init(lengthAndBytesAlreadyCached.first, lengthAndBytesAlreadyCached.second); - bytesLeft = lengthAndBytesAlreadyCached.first; - } else { - bytesLeft = getRequestLength(dataSpec, cache, key); + progressNotifier.init(requestLength, bytesCached); } long position = dataSpec.position; + long bytesLeft = requestLength; while (bytesLeft != 0) { throwExceptionIfCanceled(isCanceled); - long blockLength = cache.getCachedLength(key, position, bytesLeft); + long blockLength = cache.getCachedLength(cacheKey, position, bytesLeft); if (blockLength > 0) { // Skip already cached data. } else { @@ -206,15 +176,6 @@ public final class CacheUtil { } } - private static long getRequestLength(DataSpec dataSpec, Cache cache, String key) { - if (dataSpec.length != C.LENGTH_UNSET) { - return dataSpec.length; - } else { - long contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(key)); - return contentLength == C.LENGTH_UNSET ? C.LENGTH_UNSET : contentLength - dataSpec.position; - } - } - /** * Reads and discards all data specified by the {@code dataSpec}. * @@ -309,12 +270,6 @@ public final class CacheUtil { } } - private static String buildCacheKey( - DataSpec dataSpec, @Nullable CacheKeyFactory cacheKeyFactory) { - return (cacheKeyFactory != null ? cacheKeyFactory : CacheKeyFactory.DEFAULT) - .buildCacheKey(dataSpec); - } - private static void throwExceptionIfCanceled(@Nullable AtomicBoolean isCanceled) throws InterruptedIOException { if (isCanceled != null && isCanceled.get()) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java index eba664648b..cc7a232b3f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -20,7 +20,6 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; import android.net.Uri; -import android.util.Pair; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -104,65 +103,6 @@ public final class CacheUtilTest { Util.recursiveDelete(tempFolder); } - @Test - public void getCachedNoData() { - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(C.LENGTH_UNSET); - assertThat(contentLengthAndBytesCached.second).isEqualTo(0); - } - - @Test - public void getCachedDataUnknownLength() { - // Mock there is 100 bytes cached at the beginning - mockCache.spansAndGaps = new int[] {100}; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(C.LENGTH_UNSET); - assertThat(contentLengthAndBytesCached.second).isEqualTo(100); - } - - @Test - public void getCachedNoDataKnownLength() { - mockCache.contentLength = 1000; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(1000); - assertThat(contentLengthAndBytesCached.second).isEqualTo(0); - } - - @Test - public void getCached() { - mockCache.contentLength = 1000; - mockCache.spansAndGaps = new int[] {100, 100, 200}; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(1000); - assertThat(contentLengthAndBytesCached.second).isEqualTo(300); - } - - @Test - public void getCachedFromNonZeroPosition() { - mockCache.contentLength = 1000; - mockCache.spansAndGaps = new int[] {100, 100, 200}; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test"), /* position= */ 100, /* length= */ C.LENGTH_UNSET), - mockCache, - /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(900); - assertThat(contentLengthAndBytesCached.second).isEqualTo(200); - } - @Test public void cache() throws Exception { FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); From 1bc8503a9bc55c8eba69c46ce6dfcd6a2c86d433 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 21 May 2020 12:34:23 +0100 Subject: [PATCH 0291/1052] Check DefaultAudioSink supports passthrough Previously if the AudioCapabilities reported that an encoding/channel count was supported, DefaultAudioSink could try to play it via passthrough. However, DefaultAudioSink does not support passthrough of every possible format (for example, it's likely that AAC passthrough does not work given it's never been tested and recent GitHub issues indicate that trying to use it leads to no audio being played). Add additional checks to make sure the encoding is in the list of encodings that are known to work with passthrough in DefaultAudioSink. issue:#7404 PiperOrigin-RevId: 312651358 --- RELEASENOTES.md | 3 +++ .../exoplayer2/audio/DefaultAudioSink.java | 22 +++++++++++++++---- .../audio/DefaultAudioSinkTest.java | 11 ++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 6dafa1530a..23e28024d8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -157,6 +157,9 @@ and `AudioSink.handleBuffer` to allow batching multiple encoded frames in one buffer. * No longer use a `MediaCodec` in audio passthrough mode. + * Check `DefaultAudioSink` supports passthrough, in addition to checking + the `AudioCapabilities` + ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index a1cdc6023c..6b06a7f678 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -417,10 +417,7 @@ public final class DefaultAudioSink implements AudioSink { // channels to the output device's required number of channels. return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; } else { - return audioCapabilities != null - && audioCapabilities.supportsEncoding(encoding) - && (channelCount == Format.NO_VALUE - || channelCount <= audioCapabilities.getMaxChannelCount()); + return isPassthroughPlaybackSupported(encoding, channelCount); } } @@ -1183,6 +1180,23 @@ public final class DefaultAudioSink implements AudioSink { : writtenEncodedFrames; } + private boolean isPassthroughPlaybackSupported(@C.Encoding int encoding, int channelCount) { + // Check for encodings that are known to work for passthrough with the implementation in this + // class. This avoids trying to use passthrough with an encoding where the device/app reports + // it's capable but it is untested or known to be broken (for example AAC-LC). + return audioCapabilities != null + && audioCapabilities.supportsEncoding(encoding) + && (encoding == C.ENCODING_AC3 + || encoding == C.ENCODING_E_AC3 + || encoding == C.ENCODING_E_AC3_JOC + || encoding == C.ENCODING_AC4 + || encoding == C.ENCODING_DTS + || encoding == C.ENCODING_DTS_HD + || encoding == C.ENCODING_DOLBY_TRUEHD) + && (channelCount == Format.NO_VALUE + || channelCount <= audioCapabilities.getMaxChannelCount()); + } + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index 6fb27f46c9..4636e6fc14 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -215,6 +215,17 @@ public final class DefaultAudioSinkTest { .isTrue(); } + @Test + public void audioSinkWithAacAudioCapabilitiesWithoutOffload_doesNotSupportAacOutput() { + DefaultAudioSink defaultAudioSink = + new DefaultAudioSink( + new AudioCapabilities(new int[] {C.ENCODING_AAC_LC}, 2), new AudioProcessor[0]); + assertThat( + defaultAudioSink.supportsOutput( + CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_AAC_LC)) + .isFalse(); + } + private void configureDefaultAudioSink(int channelCount) throws AudioSink.ConfigurationException { configureDefaultAudioSink(channelCount, /* trimStartFrames= */ 0, /* trimEndFrames= */ 0); } From 2d52b0d5cf389070383d1501fb96295fb1ed0457 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 21 May 2020 14:24:12 +0100 Subject: [PATCH 0292/1052] Add createMediaSource method with manifest and media item This change adds an overloaded createMediaSource method which allows developers to pass in a media item with a in-memory manifest. Without this method the developer would not have a chance to pass in a non-dummy media item when using the factory for creting a DASH media source with an in-memory manifest. PiperOrigin-RevId: 312660418 --- .../source/dash/DashMediaSource.java | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 8b69500c94..fe74eff210 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -264,18 +264,47 @@ public final class DashMediaSource extends BaseMediaSource { * @throws IllegalArgumentException If {@link DashManifest#dynamic} is true. */ public DashMediaSource createMediaSource(DashManifest manifest) { - Assertions.checkArgument(!manifest.dynamic); - if (!streamKeys.isEmpty()) { - manifest = manifest.copy(streamKeys); - } - return new DashMediaSource( + return createMediaSource( + manifest, new MediaItem.Builder() .setUri(Uri.EMPTY) .setMediaId(DUMMY_MEDIA_ID) .setMimeType(MimeTypes.APPLICATION_MPD) .setStreamKeys(streamKeys) .setTag(tag) - .build(), + .build()); + } + + /** + * Returns a new {@link DashMediaSource} using the current parameters and the specified + * sideloaded manifest. + * + * @param manifest The manifest. {@link DashManifest#dynamic} must be false. + * @param mediaItem The {@link MediaItem} to be included in the timeline. + * @return The new {@link DashMediaSource}. + * @throws IllegalArgumentException If {@link DashManifest#dynamic} is true. + */ + public DashMediaSource createMediaSource(DashManifest manifest, MediaItem mediaItem) { + Assertions.checkArgument(!manifest.dynamic); + List streamKeys = + mediaItem.playbackProperties != null && !mediaItem.playbackProperties.streamKeys.isEmpty() + ? mediaItem.playbackProperties.streamKeys + : this.streamKeys; + if (!streamKeys.isEmpty()) { + manifest = manifest.copy(streamKeys); + } + boolean hasUri = mediaItem.playbackProperties != null; + boolean hasTag = hasUri && mediaItem.playbackProperties.tag != null; + mediaItem = + mediaItem + .buildUpon() + .setMimeType(MimeTypes.APPLICATION_MPD) + .setUri(hasUri ? mediaItem.playbackProperties.uri : Uri.EMPTY) + .setTag(hasTag ? mediaItem.playbackProperties.tag : tag) + .setStreamKeys(streamKeys) + .build(); + return new DashMediaSource( + mediaItem, manifest, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, From d487170eb01dd6edfeb65933d9a4f0627039fb3c Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 21 May 2020 14:25:29 +0100 Subject: [PATCH 0293/1052] Comment out unused code. The code that uses these variables is already commneted out. Android Studio complains about unused variables and code and it's better to comment them out as long as they are not used. PiperOrigin-RevId: 312660512 --- .../source/hls/HlsSampleStreamWrapper.java | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 9f0a7f1f0d..b42a324f0d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -1388,22 +1388,25 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; */ private static final class HlsSampleQueue extends SampleQueue { - /** - * 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; + // 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 overridingDrmInitData; @Nullable private DrmInitData drmInitData; @@ -1419,16 +1422,18 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } public void setSourceChunk(HlsMediaChunk chunk) { - sourceChunk = chunk; - sourceChunkLastSampleTimeUs = C.TIME_UNSET; sourceId(chunk.uid); - 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; + // 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) { @@ -1506,7 +1511,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // new UnexpectedSampleTimestampException( // sourceChunk, sourceChunkLastSampleTimeUs, timeUs)); // } - sourceChunkLastSampleTimeUs = timeUs; + // sourceChunkLastSampleTimeUs = timeUs; super.sampleMetadata(timeUs, flags, size, offset, cryptoData); } } From dac3dde7bb8d79e785f65b0c0c7b7425f41dffa6 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 21 May 2020 18:41:42 +0100 Subject: [PATCH 0294/1052] Fix comparison in ChunkSampleStream. A recent change tried to make this condition clearer by using Integer.MAX_VALUE, but this only works if the comparison also compares against larger values. PiperOrigin-RevId: 312697530 --- .../android/exoplayer2/source/chunk/ChunkSampleStream.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index 4b1dc54037..b73a086009 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -635,7 +635,7 @@ public class ChunkSampleStream implements SampleStream, S break; } } - if (newQueueSize == currentQueueSize) { + if (newQueueSize >= currentQueueSize) { return; } From cf2214ddaa5c131ad33edca0793f50345100b9bd Mon Sep 17 00:00:00 2001 From: Noam Tamim Date: Sun, 24 May 2020 15:20:18 +0300 Subject: [PATCH 0295/1052] Assume default 4G (instead of Wifi) bitrate for 5G --- .../android/exoplayer2/upstream/DefaultBandwidthMeter.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index ceaefad0b9..e4bcc58c3b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -203,10 +203,11 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList result.append(C.NETWORK_TYPE_2G, DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[1]]); result.append(C.NETWORK_TYPE_3G, DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[2]]); result.append(C.NETWORK_TYPE_4G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); - // Assume default Wifi bitrate for Ethernet and 5G to prevent using the slower fallback. + // Assume default Wifi and 4G bitrate for Ethernet and 5G, respectively, to prevent using the + // slower fallback. result.append( C.NETWORK_TYPE_ETHERNET, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); - result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); + result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); return result; } From 1f125425a829497c598fe4a173afb71395102fe3 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 21 May 2020 19:59:46 +0100 Subject: [PATCH 0296/1052] Add Guava dep to exo-workmanager module This fixes the "cannot access ListenableFuture" build error, even though it seems on the surface like we shouldn't need a Guava dependency here. There's more info about what's going on here: https://blog.gradle.org/guava PiperOrigin-RevId: 312712991 --- extensions/workmanager/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/workmanager/build.gradle b/extensions/workmanager/build.gradle index 6025ecfcd0..36df826adb 100644 --- a/extensions/workmanager/build.gradle +++ b/extensions/workmanager/build.gradle @@ -35,6 +35,10 @@ android { dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.work:work-runtime:2.3.4' + // Guava & Gradle interact badly, and this prevents + // "cannot access ListenableFuture" errors [internal b/157225611]. + // More info: https://blog.gradle.org/guava + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion } From eddc2b0b33f3eaf0c0302ca00b5bde2311b8afe0 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 26 May 2020 08:49:56 +0100 Subject: [PATCH 0297/1052] Enable multidex for demos This is necessary now we have Guava in debug (no-minified) apps. Also switch to AndroidX multidex to remove the support library dependency. Temporarily we need to add an Application class, as internal jetification doesn't seem to handle declaring MultiDexApplication in AndroidManifest.xml. issue:#7421 PiperOrigin-RevId: 313145023 --- constants.gradle | 1 + demos/cast/build.gradle | 2 ++ .../exoplayer2/castdemo/DemoApplication.java | 23 +++++++++++++++++++ demos/main/build.gradle | 2 ++ .../exoplayer2/demo/DemoApplication.java | 4 ++-- extensions/ima/build.gradle | 2 +- 6 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java diff --git a/constants.gradle b/constants.gradle index b0a0e4fce5..852da69b07 100644 --- a/constants.gradle +++ b/constants.gradle @@ -31,6 +31,7 @@ project.ext { androidxAppCompatVersion = '1.1.0' androidxCollectionVersion = '1.1.0' androidxMediaVersion = '1.0.1' + androidxMultidexVersion = '2.0.0' androidxTestCoreVersion = '1.2.0' androidxTestJUnitVersion = '1.1.1' androidxTestRunnerVersion = '1.2.0' diff --git a/demos/cast/build.gradle b/demos/cast/build.gradle index c929f09c87..b26112e15a 100644 --- a/demos/cast/build.gradle +++ b/demos/cast/build.gradle @@ -27,6 +27,7 @@ android { versionCode project.ext.releaseVersionCode minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.appTargetSdkVersion + multiDexEnabled true } buildTypes { @@ -57,6 +58,7 @@ dependencies { implementation project(modulePrefix + 'library-ui') implementation project(modulePrefix + 'extension-cast') implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'androidx.multidex:multidex:' + androidxMultidexVersion implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'com.google.android.material:material:1.1.0' } diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java new file mode 100644 index 0000000000..f2d2288b6a --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java @@ -0,0 +1,23 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.castdemo; + +import androidx.multidex.MultiDexApplication; + +// Note: Multidex is enabled in code not AndroidManifest.xml because the internal build system +// doesn't dejetify MultiDexApplication in AndroidManifest.xml. +/** Application for multidex support. */ +public final class DemoApplication extends MultiDexApplication {} diff --git a/demos/main/build.gradle b/demos/main/build.gradle index b7a8666fe3..f26fd7dc32 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -27,6 +27,7 @@ android { versionCode project.ext.releaseVersionCode minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.appTargetSdkVersion + multiDexEnabled true } buildTypes { @@ -64,6 +65,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'androidx.multidex:multidex:' + androidxMultidexVersion implementation 'com.google.android.material:material:1.1.0' implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-dash') diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java index c36d370992..86978a1613 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.demo; -import android.app.Application; +import androidx.multidex.MultiDexApplication; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.database.DatabaseProvider; @@ -40,7 +40,7 @@ import java.io.IOException; /** * Placeholder application to facilitate overriding Application methods for debugging and testing. */ -public class DemoApplication extends Application { +public class DemoApplication extends MultiDexApplication { public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index f25a26b3a9..28f201e24b 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -45,9 +45,9 @@ dependencies { implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion androidTestImplementation project(modulePrefix + 'testutils') + androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion - androidTestImplementation 'com.android.support:multidex:1.0.3' androidTestCompileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'com.google.guava:guava:' + guavaVersion From f099f570e64ae771e9cab3ef0610c8235b3ddc0b Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 26 May 2020 08:52:37 +0100 Subject: [PATCH 0298/1052] Update TrackSelectionDialogBuilder to use androidx compat Dialog. This ensure style themes are correctly applied. issue:#7357 PiperOrigin-RevId: 313145345 --- RELEASENOTES.md | 2 ++ library/ui/build.gradle | 1 + .../android/exoplayer2/ui/TrackSelectionDialogBuilder.java | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index dbff14cbd8..317852ef21 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -194,6 +194,8 @@ * Add `showScrubber` and `hideScrubber` methods to DefaultTimeBar. * Move logic of prev, next, fast forward and rewind to ControlDispatcher ([#6926](https://github.com/google/ExoPlayer/issues/6926)). + * Update `TrackSelectionDialogBuilder` to use androidx compat Dialog + ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Metadata: Add minimal DVB Application Information Table (AIT) support ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue diff --git a/library/ui/build.gradle b/library/ui/build.gradle index f404ee38a5..5534b5bf48 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation project(modulePrefix + 'library-core') api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java index f8a016bc8b..5c91645a4c 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java @@ -15,12 +15,12 @@ */ package com.google.android.exoplayer2.ui; -import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; From ee11d9d6fb1e62ff760f6ddd8c32318fa5c1573a Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 26 May 2020 09:45:36 +0100 Subject: [PATCH 0299/1052] Make manifest loads on timeline refresh optional in FakeMediaSource. Every timeline refresh currently assumes that it corresponds to a manifest refresh and issues the respective load events. However, there are other timeline updates that don't have a manifest refresh (e.g. ad state updates)and thus shouldn't issue these events. PiperOrigin-RevId: 313150489 --- .../analytics/AnalyticsCollectorTest.java | 9 ++---- .../exoplayer2/testutil/FakeMediaSource.java | 30 ++++++++++++++----- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 891d7f28bb..5721212567 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -849,7 +849,8 @@ public final class AnalyticsCollectorTest { /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - adPlaybackState.get()))); + adPlaybackState.get())), + /* sendManifestLoadEvents= */ false); } } }); @@ -956,25 +957,19 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* content manifest */, - WINDOW_0 /* preroll manifest */, prerollAd, contentAfterPreroll, - WINDOW_0 /* midroll manifest */, midrollAd, contentAfterMidroll, - WINDOW_0 /* postroll manifest */, postrollAd, contentAfterPostroll); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* content manifest */, - WINDOW_0 /* preroll manifest */, prerollAd, contentAfterPreroll, - WINDOW_0 /* midroll manifest */, midrollAd, contentAfterMidroll, - WINDOW_0 /* postroll manifest */, postrollAd, contentAfterPostroll); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index cb55127c22..70b67e9fc1 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -40,9 +40,9 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -159,7 +159,7 @@ public class FakeMediaSource extends BaseMediaSource { releasedSource = false; sourceInfoRefreshHandler = Util.createHandler(); if (timeline != null) { - finishSourcePreparation(); + finishSourcePreparation(/* sendManifestLoadEvents= */ true); } } @@ -209,15 +209,29 @@ public class FakeMediaSource extends BaseMediaSource { /** * Sets a new timeline. If the source is already prepared, this triggers a source info refresh * message being sent to the listener. + * + * @param newTimeline The new {@link Timeline}. */ - public synchronized void setNewSourceInfo(final Timeline newTimeline) { + public void setNewSourceInfo(Timeline newTimeline) { + setNewSourceInfo(newTimeline, /* sendManifestLoadEvents= */ true); + } + + /** + * Sets a new timeline. If the source is already prepared, this triggers a source info refresh + * message being sent to the listener. + * + * @param newTimeline The new {@link Timeline}. + * @param sendManifestLoadEvents Whether to treat this as a manifest refresh and send manifest + * load events to listeners. + */ + public synchronized void setNewSourceInfo(Timeline newTimeline, boolean sendManifestLoadEvents) { if (sourceInfoRefreshHandler != null) { sourceInfoRefreshHandler.post( () -> { assertThat(releasedSource).isFalse(); assertThat(preparedSource).isTrue(); timeline = newTimeline; - finishSourcePreparation(); + finishSourcePreparation(sendManifestLoadEvents); }); } else { timeline = newTimeline; @@ -270,9 +284,9 @@ public class FakeMediaSource extends BaseMediaSource { trackGroupArray, drmSessionManager, eventDispatcher, /* deferOnPrepared= */ false); } - private void finishSourcePreparation() { + private void finishSourcePreparation(boolean sendManifestLoadEvents) { refreshSourceInfo(Assertions.checkStateNotNull(timeline)); - if (!timeline.isEmpty()) { + if (!timeline.isEmpty() && sendManifestLoadEvents) { MediaLoadData mediaLoadData = new MediaLoadData( C.DATA_TYPE_MANIFEST, @@ -290,7 +304,7 @@ public class FakeMediaSource extends BaseMediaSource { loadTaskId, FAKE_DATA_SPEC, FAKE_DATA_SPEC.uri, - /* responseHeaders= */ Collections.emptyMap(), + /* responseHeaders= */ ImmutableMap.of(), elapsedRealTimeMs, /* loadDurationMs= */ 0, /* bytesLoaded= */ 0), @@ -300,7 +314,7 @@ public class FakeMediaSource extends BaseMediaSource { loadTaskId, FAKE_DATA_SPEC, FAKE_DATA_SPEC.uri, - /* responseHeaders= */ Collections.emptyMap(), + /* responseHeaders= */ ImmutableMap.of(), elapsedRealTimeMs, /* loadDurationMs= */ 0, /* bytesLoaded= */ MANIFEST_LOAD_BYTES), From 03ea39b17521263a99b163d0c004947cece051f7 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 26 May 2020 10:04:03 +0100 Subject: [PATCH 0300/1052] ConditionVariable: Add uninterruptible block Sometimes it's useful to be able to block until something on some other thread "really has finished". This will be needed for moving caching operations onto the executor in Downloader implementations, since we need to guarantee that Downloader.download doesn't return until it's no longer modifying the underlying cache. One solution to this is of course just to not interrupt the thread that's blocking on the condition variable, but there are cases where you do need to do this in case the thread is at some other point in its execution. This is true for Downloader implementations, where the Download.download thread will also be blocking on PriorityTaskManager.proceed. Arranging to conditionally interrupt the thread based on where it's got to is probably possible, but seems complicated and error prone. Issue: #5978 PiperOrigin-RevId: 313152413 --- .../exoplayer2/util/ConditionVariable.java | 20 +++++ .../util/ConditionVariableTest.java | 89 +++++++++++++------ 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java index b7f0d04e23..e5665c76ff 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java @@ -111,6 +111,26 @@ public class ConditionVariable { return isOpen; } + /** + * Blocks until the condition is open. Unlike {@link #block}, this method will continue to block + * if the calling thread is interrupted. If the calling thread was interrupted then its {@link + * Thread#isInterrupted() interrupted status} will be set when the method returns. + */ + public synchronized void blockUninterruptible() { + boolean wasInterrupted = false; + while (!isOpen) { + try { + wait(); + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); + } + } + /** Returns whether the condition is opened. */ public synchronized boolean isOpen() { return isOpen; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java index 8f2fb2ed14..e7e0d8911a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java @@ -49,34 +49,7 @@ public class ConditionVariableTest { } @Test - public void blockWithoutTimeout_blocks() throws InterruptedException { - ConditionVariable conditionVariable = buildTestConditionVariable(); - - AtomicBoolean blockReturned = new AtomicBoolean(); - AtomicBoolean blockWasInterrupted = new AtomicBoolean(); - Thread blockingThread = - new Thread( - () -> { - try { - conditionVariable.block(); - blockReturned.set(true); - } catch (InterruptedException e) { - blockWasInterrupted.set(true); - } - }); - - blockingThread.start(); - Thread.sleep(500); - assertThat(blockReturned.get()).isFalse(); - - blockingThread.interrupt(); - blockingThread.join(); - assertThat(blockWasInterrupted.get()).isTrue(); - assertThat(conditionVariable.isOpen()).isFalse(); - } - - @Test - public void blockWithMaxTimeout_blocks() throws InterruptedException { + public void blockWithMaxTimeout_blocks_thenThrowsWhenInterrupted() throws InterruptedException { ConditionVariable conditionVariable = buildTestConditionVariable(); AtomicBoolean blockReturned = new AtomicBoolean(); @@ -103,7 +76,34 @@ public class ConditionVariableTest { } @Test - public void open_unblocksBlock() throws InterruptedException { + public void block_blocks_thenThrowsWhenInterrupted() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void block_blocks_thenReturnsWhenOpened() throws InterruptedException { ConditionVariable conditionVariable = buildTestConditionVariable(); AtomicBoolean blockReturned = new AtomicBoolean(); @@ -129,6 +129,37 @@ public class ConditionVariableTest { assertThat(conditionVariable.isOpen()).isTrue(); } + @Test + public void blockUnterruptible_blocksIfInterrupted_thenUnblocksWhenOpened() + throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean interruptedStatusSet = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + conditionVariable.blockUninterruptible(); + blockReturned.set(true); + interruptedStatusSet.set(Thread.currentThread().isInterrupted()); + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + Thread.sleep(500); + // blockUninterruptible should still be blocked. + assertThat(blockReturned.get()).isFalse(); + + conditionVariable.open(); + blockingThread.join(); + // blockUninterruptible should have set the thread's interrupted status on exit. + assertThat(interruptedStatusSet.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isTrue(); + } + private static ConditionVariable buildTestConditionVariable() { return new ConditionVariable( new SystemClock() { From 0add067eaad1bfb5f035b63de14222e289250f0a Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 26 May 2020 13:17:38 +0100 Subject: [PATCH 0301/1052] Make fallback value more explicitly unset. PiperOrigin-RevId: 313171970 --- .../android/exoplayer2/source/chunk/ChunkSampleStream.java | 4 ++-- .../exoplayer2/source/hls/HlsSampleStreamWrapper.java | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index b73a086009..6efe25420c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -628,14 +628,14 @@ public class ChunkSampleStream implements SampleStream, S return; } - int newQueueSize = Integer.MAX_VALUE; + int newQueueSize = C.LENGTH_UNSET; for (int i = preferredQueueSize; i < currentQueueSize; i++) { if (!haveReadFromMediaChunk(i)) { newQueueSize = i; break; } } - if (newQueueSize >= currentQueueSize) { + if (newQueueSize == C.LENGTH_UNSET) { return; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index efedad7a96..79f4d975fc 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -881,15 +881,14 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private void discardUpstream(int preferredQueueSize) { Assertions.checkState(!loader.isLoading()); - int currentQueueSize = mediaChunks.size(); - int newQueueSize = Integer.MAX_VALUE; - for (int i = preferredQueueSize; i < currentQueueSize; i++) { + int newQueueSize = C.LENGTH_UNSET; + for (int i = preferredQueueSize; i < mediaChunks.size(); i++) { if (!haveReadFromMediaChunkDiscardRange(i)) { newQueueSize = i; break; } } - if (newQueueSize >= currentQueueSize) { + if (newQueueSize == C.LENGTH_UNSET) { return; } From 45b574d593d07c31247500268af8941f33539891 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 26 May 2020 14:02:19 +0100 Subject: [PATCH 0302/1052] Fix flaky test. The test was flaky because it didn't wait for pending commands to finish after pausing the test playback. Also add more debug information to the toString() method because the expected and actual state only differed in the nextAdGroupIndex in the flaky case. PiperOrigin-RevId: 313175919 --- .../android/exoplayer2/analytics/AnalyticsCollectorTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 5721212567..74b83d7873 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -874,7 +874,9 @@ public final class AnalyticsCollectorTest { // Wait in each content part to ensure previously triggered events get a chance to be // delivered. This prevents flakiness caused by playback progressing too fast. .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 3_000) + .waitForPendingPlayerCommands() .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 8_000) + .waitForPendingPlayerCommands() .play() .waitForPlaybackState(Player.STATE_ENDED) // Wait for final timeline change that marks post-roll played. @@ -1361,6 +1363,9 @@ public final class AnalyticsCollectorTest { : "") + ", period.hashCode=" + mediaPeriodId.periodUid.hashCode() + + (mediaPeriodId.nextAdGroupIndex != C.INDEX_UNSET + ? ", nextAdGroup=" + mediaPeriodId.nextAdGroupIndex + : "") + '}' : "{" + "window=" + windowIndex + ", period = null}"; } From 151ea531b1f49b1b3684a54ce05da7a7b9d8fc1d Mon Sep 17 00:00:00 2001 From: krocard Date: Tue, 26 May 2020 15:28:57 +0100 Subject: [PATCH 0303/1052] Make constants more readable with _ separator Add an `_` in long constants. Eg: 10000 => 10_000. I'm proposing this change because I have had multiple missread due to confusing the number of 0 in a long number. More specifically, added an underscore to all number matching: `final.*\ [0-9]{2,}000;` PiperOrigin-RevId: 313186920 --- .../exoplayer2/ext/opus/OpusDecoder.java | 6 ++---- .../exoplayer2/DefaultControlDispatcher.java | 2 +- .../android/exoplayer2/DefaultLoadControl.java | 4 ++-- .../audio/AudioTrackPositionTracker.java | 4 ++-- .../exoplayer2/audio/DefaultAudioSink.java | 18 ++++++------------ .../source/ProgressiveMediaPeriod.java | 2 +- .../trackselection/AdaptiveTrackSelection.java | 6 +++--- .../DefaultLoadErrorHandlingPolicy.java | 2 +- .../video/VideoFrameReleaseTimeHelper.java | 2 +- .../video/spherical/CameraMotionRenderer.java | 2 +- .../video/spherical/ProjectionDecoder.java | 2 +- .../android/exoplayer2/ExoPlayerTest.java | 2 +- .../analytics/AnalyticsCollectorTest.java | 2 +- .../SilenceSkippingAudioProcessorTest.java | 2 +- .../source/ClippingMediaSourceTest.java | 4 ++-- .../source/dash/DashMediaSource.java | 4 ++-- .../extractor/mkv/MatroskaExtractor.java | 4 ++-- .../extractor/ogg/DefaultOggSeeker.java | 6 +++--- .../exoplayer2/extractor/ogg/OpusReader.java | 6 ++---- .../extractor/ts/PsBinarySearchSeeker.java | 2 +- .../extractor/ts/PsDurationReader.java | 2 +- .../source/smoothstreaming/SsMediaSource.java | 4 ++-- .../exoplayer2/testutil/DummyMainThread.java | 2 +- .../exoplayer2/testutil/FakeRenderer.java | 2 +- .../testutil/MediaSourceTestRunner.java | 2 +- .../exoplayer2/testutil/FakeClockTest.java | 2 +- 26 files changed, 43 insertions(+), 53 deletions(-) diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java index 8795950671..c82636ca5a 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java @@ -36,10 +36,8 @@ import java.util.List; private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; - /** - * Opus streams are always decoded at 48000 Hz. - */ - private static final int SAMPLE_RATE = 48000; + /** Opus streams are always decoded at 48000 Hz. */ + private static final int SAMPLE_RATE = 48_000; private static final int NO_ERROR = 0; private static final int DECODE_ERROR = -1; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java index 7f24e6113f..bc655d9a1b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java @@ -19,7 +19,7 @@ package com.google.android.exoplayer2; public class DefaultControlDispatcher implements ControlDispatcher { /** The default fast forward increment, in milliseconds. */ - public static final int DEFAULT_FAST_FORWARD_MS = 15000; + public static final int DEFAULT_FAST_FORWARD_MS = 15_000; /** The default rewind increment, in milliseconds. */ public static final int DEFAULT_REWIND_MS = 5000; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java index 5eb14021a3..3be234f3f9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java @@ -32,12 +32,12 @@ public class DefaultLoadControl implements LoadControl { * The default minimum duration of media that the player will attempt to ensure is buffered at all * times, in milliseconds. */ - public static final int DEFAULT_MIN_BUFFER_MS = 50000; + public static final int DEFAULT_MIN_BUFFER_MS = 50_000; /** * The default maximum duration of media that the player will attempt to buffer, in milliseconds. */ - public static final int DEFAULT_MAX_BUFFER_MS = 50000; + public static final int DEFAULT_MAX_BUFFER_MS = 50_000; /** * The default duration of media that must be buffered for playback to start or resume following a diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index 4ee70bd813..b3e232df22 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -127,8 +127,8 @@ import java.lang.reflect.Method; private static final long FORCE_RESET_WORKAROUND_TIMEOUT_MS = 200; private static final int MAX_PLAYHEAD_OFFSET_COUNT = 10; - private static final int MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US = 30000; - private static final int MIN_LATENCY_SAMPLE_INTERVAL_US = 500000; + private static final int MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US = 30_000; + private static final int MIN_LATENCY_SAMPLE_INTERVAL_US = 50_0000; private final Listener listener; private final long[] playheadOffsets; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 6b06a7f678..27bbeb91f6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -198,18 +198,12 @@ public final class DefaultAudioSink implements AudioSink { } } - /** - * A minimum length for the {@link AudioTrack} buffer, in microseconds. - */ - private static final long MIN_BUFFER_DURATION_US = 250000; - /** - * A maximum length for the {@link AudioTrack} buffer, in microseconds. - */ - private static final long MAX_BUFFER_DURATION_US = 750000; - /** - * The length for passthrough {@link AudioTrack} buffers, in microseconds. - */ - private static final long PASSTHROUGH_BUFFER_DURATION_US = 250000; + /** A minimum length for the {@link AudioTrack} buffer, in microseconds. */ + private static final long MIN_BUFFER_DURATION_US = 250_000; + /** A maximum length for the {@link AudioTrack} buffer, in microseconds. */ + private static final long MAX_BUFFER_DURATION_US = 750_000; + /** The length for passthrough {@link AudioTrack} buffers, in microseconds. */ + private static final long PASSTHROUGH_BUFFER_DURATION_US = 250_000; /** * A multiplication factor to apply to the minimum buffer size requested by the underlying * {@link AudioTrack}. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 2630132044..0f5d2ce344 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -89,7 +89,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * When the source's duration is unknown, it is calculated by adding this value to the largest * sample timestamp seen when buffering completes. */ - private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10000; + private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10_000; private static final Map ICY_METADATA_HEADERS = createIcyMetadataHeaders(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java index 9a599279ec..8b2bd4581c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java @@ -283,9 +283,9 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { } } - public static final int DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS = 10000; - public static final int DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS = 25000; - public static final int DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS = 25000; + public static final int DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS = 10_000; + public static final int DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS = 25_000; + public static final int DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS = 25_000; public static final float DEFAULT_BANDWIDTH_FRACTION = 0.7f; public static final float DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE = 0.75f; public static final long DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS = 2000; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java index a5e30b5ccc..b623d7bfe1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java @@ -33,7 +33,7 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { */ public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT_PROGRESSIVE_LIVE = 6; /** The default duration for which a track is blacklisted in milliseconds. */ - public static final long DEFAULT_TRACK_BLACKLIST_MS = 60000; + public static final long DEFAULT_TRACK_BLACKLIST_MS = 60_000; private static final int DEFAULT_BEHAVIOR_MIN_LOADABLE_RETRY_COUNT = -1; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java index 2134772d9c..01b296e747 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java @@ -36,7 +36,7 @@ import com.google.android.exoplayer2.util.Util; public final class VideoFrameReleaseTimeHelper { private static final long CHOREOGRAPHER_SAMPLE_DELAY_MILLIS = 500; - private static final long MAX_ALLOWED_DRIFT_NS = 20000000; + private static final long MAX_ALLOWED_DRIFT_NS = 20_000_000; private static final long VSYNC_OFFSET_PERCENTAGE = 80; private static final int MIN_FRAMES_FOR_ADJUSTMENT = 6; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java index abf08f3b4e..1960a4b534 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java @@ -35,7 +35,7 @@ public class CameraMotionRenderer extends BaseRenderer { private static final String TAG = "CameraMotionRenderer"; // The amount of time to read samples ahead of the current time. - private static final int SAMPLE_WINDOW_DURATION_US = 100000; + private static final int SAMPLE_WINDOW_DURATION_US = 100_000; private final DecoderInputBuffer buffer; private final ParsableByteArray scratch; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java index eadc617ea7..c5a1a76895 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java @@ -45,7 +45,7 @@ public final class ProjectionDecoder { // Sanity limits to prevent a bad file from creating an OOM situation. We don't expect a mesh to // exceed these limits. - private static final int MAX_COORDINATE_COUNT = 10000; + private static final int MAX_COORDINATE_COUNT = 10_000; private static final int MAX_VERTEX_COUNT = 32 * 1000; private static final int MAX_TRIANGLE_INDICES = 128 * 1000; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index ad95fb11c7..788852ed98 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -118,7 +118,7 @@ public final class ExoPlayerTest { * milliseconds after starting the player before the test will time out. This is to catch cases * where the player under test is not making progress, in which case the test should fail. */ - private static final int TIMEOUT_MS = 10000; + private static final int TIMEOUT_MS = 10_000; private Context context; private Timeline dummyTimeline; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 74b83d7873..8a0ee105b6 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -134,7 +134,7 @@ public final class AnalyticsCollectorTest { private static final Format VIDEO_FORMAT_DRM_1 = ExoPlayerTestRunner.VIDEO_FORMAT.buildUpon().setDrmInitData(DRM_DATA_1).build(); - private static final int TIMEOUT_MS = 10000; + private static final int TIMEOUT_MS = 10_000; private static final Timeline SINGLE_PERIOD_TIMELINE = new FakeTimeline(/* windowCount= */ 1); private static final EventWindowAndPeriodId WINDOW_0 = new EventWindowAndPeriodId(/* windowIndex= */ 0, /* mediaPeriodId= */ null); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java index 1a78788f05..9fc9b12c02 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java @@ -37,7 +37,7 @@ public final class SilenceSkippingAudioProcessorTest { /* sampleRate= */ 1000, /* channelCount= */ 2, /* encoding= */ C.ENCODING_PCM_16BIT); private static final int TEST_SIGNAL_SILENCE_DURATION_MS = 1000; private static final int TEST_SIGNAL_NOISE_DURATION_MS = 1000; - private static final int TEST_SIGNAL_FRAME_COUNT = 100000; + private static final int TEST_SIGNAL_FRAME_COUNT = 100_000; private static final int INPUT_BUFFER_SIZE = 100; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index c371ec0451..5d47eec430 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -51,8 +51,8 @@ import org.robolectric.annotation.LooperMode.Mode; @LooperMode(Mode.PAUSED) public final class ClippingMediaSourceTest { - private static final long TEST_PERIOD_DURATION_US = 1000000; - private static final long TEST_CLIP_AMOUNT_US = 300000; + private static final long TEST_PERIOD_DURATION_US = 1_000_000; + private static final long TEST_CLIP_AMOUNT_US = 300_000; private Window window; private Period period; diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index fe74eff210..03d967e895 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -411,7 +411,7 @@ public final class DashMediaSource extends BaseMediaSource { * The default presentation delay for live streams. The presentation delay is the duration by * which the default start position precedes the end of the live window. */ - public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30000; + public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30_000; /** @deprecated Use {@link #DEFAULT_LIVE_PRESENTATION_DELAY_MS}. */ @Deprecated public static final long DEFAULT_LIVE_PRESENTATION_DELAY_FIXED_MS = @@ -432,7 +432,7 @@ public final class DashMediaSource extends BaseMediaSource { /** * The minimum default start position for live streams, relative to the start of the live window. */ - private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000; + private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5_000_000; private static final String TAG = "DashMediaSource"; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 8bb057d404..6d28e870a4 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -299,7 +299,7 @@ public class MatroskaExtractor implements Extractor { * The value by which to divide a time in microseconds to convert it to the unit of the last value * in an SSA timecode (1/100ths of a second). */ - private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10000; + private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10_000; /** * The format of an SSA timecode. */ @@ -1861,7 +1861,7 @@ public class MatroskaExtractor implements Extractor { private static final class Track { private static final int DISPLAY_UNIT_PIXELS = 0; - private static final int MAX_CHROMATICITY = 50000; // Defined in CTA-861.3. + private static final int MAX_CHROMATICITY = 50_000; // Defined in CTA-861.3. /** * Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java index 1d73a1b66a..b7d86632fb 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java @@ -30,9 +30,9 @@ import java.io.IOException; /** Seeks in an Ogg stream. */ /* package */ final class DefaultOggSeeker implements OggSeeker { - private static final int MATCH_RANGE = 72000; - private static final int MATCH_BYTE_RANGE = 100000; - private static final int DEFAULT_OFFSET = 30000; + private static final int MATCH_RANGE = 72_000; + private static final int MATCH_BYTE_RANGE = 100_000; + private static final int DEFAULT_OFFSET = 30_000; private static final int STATE_SEEK_TO_END = 0; private static final int STATE_READ_LAST_PAGE = 1; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java index 018fd949b3..9fe9ca36e6 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java @@ -32,10 +32,8 @@ import java.util.List; private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; - /** - * Opus streams are always decoded at 48000 Hz. - */ - private static final int SAMPLE_RATE = 48000; + /** Opus streams are always decoded at 48000 Hz. */ + private static final int SAMPLE_RATE = 48_000; private static final int OPUS_CODE = 0x4f707573; private static final byte[] OPUS_SIGNATURE = {'O', 'p', 'u', 's', 'H', 'e', 'a', 'd'}; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java index 09cf9b3f00..0973219804 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java @@ -35,7 +35,7 @@ import java.io.IOException; private static final long SEEK_TOLERANCE_US = 100_000; private static final int MINIMUM_SEARCH_RANGE_BYTES = 1000; - private static final int TIMESTAMP_SEARCH_BYTES = 20000; + private static final int TIMESTAMP_SEARCH_BYTES = 20_000; public PsBinarySearchSeeker( TimestampAdjuster scrTimestampAdjuster, long streamDurationUs, long inputLength) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java index 4748b832de..0a74e92ce0 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java @@ -39,7 +39,7 @@ import java.io.IOException; */ /* package */ final class PsDurationReader { - private static final int TIMESTAMP_SEARCH_BYTES = 20000; + private static final int TIMESTAMP_SEARCH_BYTES = 20_000; private final TimestampAdjuster scrTimestampAdjuster; private final ParsableByteArray packetBuffer; diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 03506284ec..24e835ee90 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -330,7 +330,7 @@ public final class SsMediaSource extends BaseMediaSource * The default presentation delay for live streams. The presentation delay is the duration by * which the default start position precedes the end of the live window. */ - public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30000; + public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30_000; /** * The minimum period between manifest refreshes. @@ -339,7 +339,7 @@ public final class SsMediaSource extends BaseMediaSource /** * The minimum default start position for live streams, relative to the start of the live window. */ - private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000; + private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5_000_000; private final boolean sideloadedManifest; private final Uri manifestUri; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java index 9678e03b40..9121d35f59 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java @@ -34,7 +34,7 @@ public final class DummyMainThread { } /** Default timeout value used for {@link #runOnMainThread(Runnable)}. */ - public static final int TIMEOUT_MS = 10000; + public static final int TIMEOUT_MS = 10_000; private final HandlerThread thread; private final Handler handler; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java index e2e6e2e27a..e4f96e0147 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java @@ -46,7 +46,7 @@ public class FakeRenderer extends BaseRenderer { * source. A real renderer will typically read ahead by a small amount due to pipelining through * decoders and the media output path. */ - private static final long SOURCE_READAHEAD_US = 250000; + private static final long SOURCE_READAHEAD_US = 250_000; private final DecoderInputBuffer buffer; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java index 610995b53a..19fbb51b33 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java @@ -49,7 +49,7 @@ import java.util.concurrent.atomic.AtomicReference; /** A runner for {@link MediaSource} tests. */ public class MediaSourceTestRunner { - public static final int TIMEOUT_MS = 10000; + public static final int TIMEOUT_MS = 10_000; private final MediaSource mediaSource; private final MediaSourceListener mediaSourceListener; diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java index 41cbf8ae15..b0511f8aba 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java @@ -33,7 +33,7 @@ import org.robolectric.annotation.LooperMode; @LooperMode(LooperMode.Mode.PAUSED) public final class FakeClockTest { - private static final long TIMEOUT_MS = 10000; + private static final long TIMEOUT_MS = 10_000; @Test public void currentTimeMillis_withoutBootTime() { From a1c72c0daf2a320c04bf1100be89ac54a27219b3 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 26 May 2020 18:20:31 +0100 Subject: [PATCH 0304/1052] Add .inOrder() to most AnalyticsCollectorTest asserts I skipped it where it didn't make sense (e.g. singleton lists, or lists where all elements are identical). PiperOrigin-RevId: 313217398 --- .../analytics/AnalyticsCollectorTest.java | 476 ++++++++++++------ 1 file changed, 319 insertions(+), 157 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 8a0ee105b6..d25cac6806 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -166,9 +166,11 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, WINDOW_0 /* ENDED */); + WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, WINDOW_0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */) + .inOrder(); listener.assertNoMoreEvents(); } @@ -187,26 +189,35 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, - period0 /* ENDED */); + period0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0 /* started */, period0 /* stopped */); + .containsExactly(period0 /* started */, period0 /* stopped */) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) - .containsExactly(WINDOW_0 /* manifest */, period0 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) - .containsExactly(WINDOW_0 /* manifest */, period0 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); @@ -235,43 +246,62 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, - period1 /* ENDED */); + period1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0, period0, period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1); + .containsExactly(period0, period0, period0, period0) + .inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) .containsExactly( - period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0, period1); + period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) + .inOrder(); + assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_READING_STARTED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) .containsExactly( - period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); + period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly( - period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); + period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period1); - assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0, period1); - assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period1); listener.assertNoMoreEvents(); } @@ -290,36 +320,51 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, - period1 /* ENDED */); + period1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0, period0, period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1); + .containsExactly(period0, period0, period0, period0) + .inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period1 /* audio */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0, period1); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); + assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_READING_STARTED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* video */, period1 /* audio */); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0 /* video */, period1 /* audio */); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period1 /* audio */); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period1); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0); @@ -361,42 +406,59 @@ public final class AnalyticsCollectorTest { period1 /* BUFFERING */, period1 /* setPlayWhenReady=true */, period1 /* READY */, - period1 /* ENDED */); + period1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period1); List loadingEvents = listener.getEvents(EVENT_LOADING_CHANGED); assertThat(loadingEvents).hasSize(4); - assertThat(loadingEvents).containsAtLeast(period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1); + assertThat(loadingEvents).containsAtLeast(period0, period0).inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0, period1); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); + assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_READING_STARTED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)) - .containsExactly(period0 /* video */, period0 /* audio */); - assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0, period1); + .containsExactly(period0 /* video */, period0 /* audio */) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); listener.assertNoMoreEvents(); @@ -435,55 +497,75 @@ public final class AnalyticsCollectorTest { period0 /* BUFFERING */, period0 /* READY */, period0 /* setPlayWhenReady=true */, - period1Seq2 /* ENDED */); + period1Seq2 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) - .containsExactly(period0, period1Seq2); + .containsExactly(period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0, period0, period0, period0, period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1Seq2); + .containsExactly(period0, period0, period0, period0, period0, period0) + .inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, + period0 /* media */, period1Seq1 /* media */, - period1Seq2 /* media */); + period1Seq2 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, + period0 /* media */, period1Seq1 /* media */, - period1Seq2 /* media */); + period1Seq2 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0, period1Seq1, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)) - .containsExactly(period0, period1Seq1); + .containsExactly(period1Seq1, period0) + .inOrder(); assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly(period0, period1Seq1, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0, period1, period0, period1Seq2); + .containsExactly(period0, period1, period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0, period0); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)) - .containsExactly(period1Seq1, period1Seq2); + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(period0, period1Seq2); + .containsExactly(period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(period0, period1Seq1, period0, period1Seq2); + .containsExactly(period0, period1Seq1, period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(period0, period1Seq1, period0, period1Seq2); + .containsExactly(period0, period1Seq1, period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(period0, period1Seq2); + .containsExactly(period0, period1Seq2) + .inOrder(); listener.assertNoMoreEvents(); } @@ -523,7 +605,8 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* BUFFERING */, period0Seq1 /* setPlayWhenReady=true */, period0Seq1 /* READY */, - period0Seq1 /* ENDED */); + period0Seq1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGE */, @@ -531,38 +614,53 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* PLAYLIST_CHANGE */, WINDOW_0 /* SOURCE_UPDATE */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0Seq0, period0Seq0, period0Seq1, period0Seq1); + .containsExactly(period0Seq0, period0Seq0, period0Seq1, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly( - period0Seq0 /* prepared */, WINDOW_0 /* setMediaSources */, period0Seq1 /* prepared */); + period0Seq0 /* prepared */, WINDOW_0 /* setMediaSources */, period0Seq1 /* prepared */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq1 /* media */); + period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq1 /* media */); + period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_DECODER_INIT)).containsExactly(period0Seq0, period0Seq1); + assertThat(listener.getEvents(EVENT_READING_STARTED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0Seq0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(period0Seq1); listener.assertNoMoreEvents(); @@ -598,7 +696,8 @@ public final class AnalyticsCollectorTest { period0Seq0 /* BUFFERING */, period0Seq0 /* setPlayWhenReady=true */, period0Seq0 /* READY */, - period0Seq0 /* ENDED */); + period0Seq0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly(WINDOW_0 /* prepared */, WINDOW_0 /* prepared */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period0Seq0); @@ -613,13 +712,15 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq0 /* media */); + period0Seq0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq0 /* media */); + period0Seq0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) .containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) @@ -677,48 +778,58 @@ public final class AnalyticsCollectorTest { period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* BUFFERING */, period1Seq0 /* READY */, - period1Seq0 /* ENDED */); + period1Seq0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, window0Period1Seq0 /* SOURCE_UPDATE (concatenated timeline replaces dummy) */, - period1Seq0 /* SOURCE_UPDATE (child sources in concatenating source moved) */); + period1Seq0 /* SOURCE_UPDATE (child sources in concatenating source moved) */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly( window0Period1Seq0, window0Period1Seq0, window0Period1Seq0, window0Period1Seq0); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(window0Period1Seq0); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( - WINDOW_0 /* manifest */, - window0Period1Seq0 /* media */, - window1Period0Seq1 /* media */); + WINDOW_0 /* manifest */, window0Period1Seq0 /* media */, window1Period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( - WINDOW_0 /* manifest */, - window0Period1Seq0 /* media */, - window1Period0Seq1 /* media */); + WINDOW_0 /* manifest */, window0Period1Seq0 /* media */, window1Period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(window1Period0Seq1); assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(window0Period1Seq0, window0Period1Seq0); + .containsExactly(window0Period1Seq0, window0Period1Seq0) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(window0Period1Seq0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(window0Period1Seq0, period1Seq0); + .containsExactly(window0Period1Seq0, period1Seq0) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0); + .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0); + .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(window0Period1Seq0, period1Seq0); + .containsExactly(window0Period1Seq0, period1Seq0) + .inOrder(); listener.assertNoMoreEvents(); } @@ -760,37 +871,52 @@ public final class AnalyticsCollectorTest { period0Seq1 /* BUFFERING */, period0Seq1 /* READY */, period0Seq1 /* setPlayWhenReady=true */, - period0Seq1 /* ENDED */); + period0Seq1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE (first item) */, period0Seq0 /* PLAYLIST_CHANGED (add) */, period0Seq0 /* SOURCE_UPDATE (second item) */, - period0Seq1 /* PLAYLIST_CHANGED (remove) */); + period0Seq1 /* PLAYLIST_CHANGED (remove) */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0Seq0, period0Seq0, period0Seq0, period0Seq0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0Seq0, period0Seq1); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) - .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) - .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0Seq0, period1Seq1); + .containsExactly(period0Seq0, period1Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0Seq0, period0Seq1); + assertThat(listener.getEvents(EVENT_READING_STARTED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0Seq0, period0Seq1, period0Seq1); - assertThat(listener.getEvents(EVENT_DECODER_INIT)).containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)) .containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) .containsExactly(period0Seq0, period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) @@ -933,21 +1059,25 @@ public final class AnalyticsCollectorTest { contentAfterPreroll /* setPlayWhenReady=true */, contentAfterMidroll /* setPlayWhenReady=false */, contentAfterMidroll /* setPlayWhenReady=true */, - contentAfterPostroll /* ENDED */); + contentAfterPostroll /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE (initial) */, contentAfterPreroll /* SOURCE_UPDATE (played preroll) */, contentAfterMidroll /* SOURCE_UPDATE (played midroll) */, - contentAfterPostroll /* SOURCE_UPDATE (played postroll) */); + contentAfterPostroll /* SOURCE_UPDATE (played postroll) */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly( - contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, contentAfterPostroll); + contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly( prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, - prerollAd, prerollAd, prerollAd, prerollAd); + prerollAd, prerollAd, prerollAd, prerollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly( prerollAd, @@ -955,7 +1085,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* content manifest */, @@ -964,7 +1095,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* content manifest */, @@ -973,7 +1105,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) .containsExactly( prerollAd, @@ -981,7 +1114,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) .containsExactly( prerollAd, @@ -989,7 +1123,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)) .containsExactly( prerollAd, contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd); @@ -1000,7 +1135,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(prerollAd); assertThat(listener.getEvents(EVENT_DECODER_INIT)) .containsExactly( @@ -1009,7 +1145,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly( prerollAd, @@ -1017,9 +1154,11 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); + .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly( prerollAd, @@ -1027,7 +1166,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) .containsExactly( prerollAd, @@ -1035,9 +1175,11 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); + .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) + .inOrder(); listener.assertNoMoreEvents(); } @@ -1103,14 +1245,16 @@ public final class AnalyticsCollectorTest { contentAfterMidroll /* BUFFERING */, midrollAd /* setPlayWhenReady=true */, midrollAd /* READY */, - contentAfterMidroll /* ENDED */); + contentAfterMidroll /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly( contentAfterMidroll /* seek */, midrollAd /* seek adjustment */, - contentAfterMidroll /* ad transition */); + contentAfterMidroll /* ad transition */) + .inOrder(); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(contentBeforeMidroll); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(contentAfterMidroll); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) @@ -1122,7 +1266,8 @@ public final class AnalyticsCollectorTest { contentBeforeMidroll, contentBeforeMidroll, midrollAd, - midrollAd); + midrollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) @@ -1131,34 +1276,45 @@ public final class AnalyticsCollectorTest { contentBeforeMidroll, midrollAd, contentAfterMidroll, - contentAfterMidroll); + contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* content manifest */, contentBeforeMidroll, midrollAd, contentAfterMidroll, - contentAfterMidroll); + contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, contentAfterMidroll, midrollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(contentBeforeMidroll, midrollAd); + .containsExactly(contentBeforeMidroll, midrollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(contentBeforeMidroll); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(contentAfterMidroll); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(contentAfterMidroll); listener.assertNoMoreEvents(); @@ -1213,7 +1369,9 @@ public final class AnalyticsCollectorTest { populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); - assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0); // The period1 release event is lost because it's posted to "ExoPlayerTest thread" after that // thread has been quit during clean-up. @@ -1233,8 +1391,12 @@ public final class AnalyticsCollectorTest { populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); - assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)).containsExactly(period0, period1); - assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)) + .containsExactly(period0, period1) + .inOrder(); // The period1 release event is lost because it's posted to "ExoPlayerTest thread" after that // thread has been quit during clean-up. assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).containsExactly(period0); From d88f5f47e6f45b24b9eb630f0bb006fcd9f511e1 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 26 May 2020 18:42:18 +0100 Subject: [PATCH 0305/1052] Turn CacheUtil into stateful CacheWriter - The new CacheWriter is simplified somewhat - Blocking on PriorityTaskManager.proceed is moved out of CacheWriter and into the Downloader tasks. This is because we want to shift only the caching parts of the Downloaders onto their Executors, whilst keeping the blocking parts on the main Downloader threads. Else we can end up "using" the Executor threads indefinitely whilst they're blocked. Issue: #5978 PiperOrigin-RevId: 313222923 --- .../offline/ProgressiveDownloader.java | 39 ++- .../exoplayer2/offline/SegmentDownloader.java | 39 ++- .../exoplayer2/upstream/cache/CacheUtil.java | 312 ------------------ .../upstream/cache/CacheWriter.java | 237 +++++++++++++ .../upstream/cache/CacheDataSourceTest.java | 44 ++- ...acheUtilTest.java => CacheWriterTest.java} | 118 +++++-- 6 files changed, 422 insertions(+), 367 deletions(-) delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java rename library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/{CacheUtilTest.java => CacheWriterTest.java} (71%) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 434ca8fd5d..42e2c7e84d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -21,9 +21,11 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.CacheWriter; +import com.google.android.exoplayer2.upstream.cache.CacheWriter.ProgressListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; +import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; import java.io.IOException; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; @@ -31,8 +33,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /** A downloader for progressive media streams. */ public final class ProgressiveDownloader implements Downloader { - private static final int BUFFER_SIZE_BYTES = 128 * 1024; - private final DataSpec dataSpec; private final CacheDataSource dataSource; private final AtomicBoolean isCanceled; @@ -104,18 +104,35 @@ public final class ProgressiveDownloader implements Downloader { if (isCanceled.get()) { return; } + + CacheWriter cacheWriter = + new CacheWriter( + dataSource, + dataSpec, + /* allowShortContent= */ false, + isCanceled, + /* temporaryBuffer= */ null, + progressListener == null ? null : new ProgressForwarder(progressListener)); + @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager(); if (priorityTaskManager != null) { priorityTaskManager.add(C.PRIORITY_DOWNLOAD); } try { - CacheUtil.cache( - dataSource, - dataSpec, - progressListener == null ? null : new ProgressForwarder(progressListener), - isCanceled, - /* enableEOFException= */ true, - /* temporaryBuffer= */ new byte[BUFFER_SIZE_BYTES]); + boolean finished = false; + while (!finished && !isCanceled.get()) { + if (priorityTaskManager != null) { + priorityTaskManager.proceed(dataSource.getUpstreamPriority()); + } + try { + cacheWriter.cache(); + finished = true; + } catch (PriorityTooLowException e) { + // The next loop iteration will block until the task is able to proceed. + } + } + } catch (InterruptedException e) { + // The download was canceled. } finally { if (priorityTaskManager != null) { priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); @@ -137,7 +154,7 @@ public final class ProgressiveDownloader implements Downloader { dataSource.getCache().removeResource(dataSource.getCacheKeyFactory().buildCacheKey(dataSpec)); } - private static final class ProgressForwarder implements CacheUtil.ProgressListener { + private static final class ProgressForwarder implements CacheWriter.ProgressListener { private final ProgressListener progressListener; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 02337248e1..28ed994168 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -28,10 +28,11 @@ import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.CacheWriter; import com.google.android.exoplayer2.upstream.cache.ContentMetadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; +import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -175,18 +176,32 @@ public abstract class SegmentDownloader> impleme segmentsDownloaded) : null; byte[] temporaryBuffer = new byte[BUFFER_SIZE_BYTES]; - for (int i = 0; i < segments.size(); i++) { - CacheUtil.cache( - dataSource, - segments.get(i).dataSpec, - progressNotifier, - isCanceled, - /* enableEOFException= */ true, - temporaryBuffer); - if (progressNotifier != null) { - progressNotifier.onSegmentDownloaded(); + int segmentIndex = 0; + while (!isCanceled.get() && segmentIndex < segments.size()) { + if (priorityTaskManager != null) { + priorityTaskManager.proceed(dataSource.getUpstreamPriority()); + } + CacheWriter cacheWriter = + new CacheWriter( + dataSource, + segments.get(segmentIndex).dataSpec, + /* allowShortContent= */ false, + isCanceled, + temporaryBuffer, + progressNotifier); + try { + cacheWriter.cache(); + segmentIndex++; + if (progressNotifier != null) { + progressNotifier.onSegmentDownloaded(); + } + } catch (PriorityTooLowException e) { + // The next loop iteration will block until the task is able to proceed, then try and + // download the same segment again. } } + } catch (InterruptedException e) { + // The download was canceled. } finally { if (priorityTaskManager != null) { priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); @@ -293,7 +308,7 @@ public abstract class SegmentDownloader> impleme && dataSpec1.httpRequestHeaders.equals(dataSpec2.httpRequestHeaders); } - private static final class ProgressNotifier implements CacheUtil.ProgressListener { + private static final class ProgressNotifier implements CacheWriter.ProgressListener { private final ProgressListener progressListener; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java deleted file mode 100644 index 1e850df278..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ /dev/null @@ -1,312 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.upstream.cache; - -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSourceException; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.PriorityTaskManager; -import com.google.android.exoplayer2.util.Util; -import java.io.EOFException; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Caching related utility methods. - */ -public final class CacheUtil { - - /** Receives progress updates during cache operations. */ - public interface ProgressListener { - - /** - * Called when progress is made during a cache operation. - * - * @param requestLength The length of the content being cached in bytes, or {@link - * C#LENGTH_UNSET} if unknown. - * @param bytesCached The number of bytes that are cached. - * @param newBytesCached The number of bytes that have been newly cached since the last progress - * update. - */ - void onProgress(long requestLength, long bytesCached, long newBytesCached); - } - - /** Default buffer size to be used while caching. */ - public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024; - - /** @deprecated Use {@link CacheKeyFactory#DEFAULT}. */ - @Deprecated - public static final CacheKeyFactory DEFAULT_CACHE_KEY_FACTORY = CacheKeyFactory.DEFAULT; - - /** - * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early - * if the end of the input is reached. - * - *

    To cancel the operation, the caller should both set {@code isCanceled} to true and interrupt - * the calling thread. - * - *

    This method may be slow and shouldn't normally be called on the main thread. - * - * @param cache A {@link Cache} to store the data. - * @param dataSpec Defines the data to be cached. - * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. - * @param progressListener A listener to receive progress updates, or {@code null}. - * @param isCanceled An optional flag that will cancel the operation if set to true. - * @throws IOException If an error occurs caching the data, or if the operation was canceled. - */ - @WorkerThread - public static void cache( - Cache cache, - DataSpec dataSpec, - DataSource upstreamDataSource, - @Nullable ProgressListener progressListener, - @Nullable AtomicBoolean isCanceled) - throws IOException { - cache( - new CacheDataSource(cache, upstreamDataSource), - dataSpec, - progressListener, - isCanceled, - /* enableEOFException= */ false, - new byte[DEFAULT_BUFFER_SIZE_BYTES]); - } - - /** - * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early - * if end of input is reached and {@code enableEOFException} is false. - * - *

    If {@code dataSource} has a {@link PriorityTaskManager}, then it's the responsibility of the - * calling code to call {@link PriorityTaskManager#add} to register with the manager before - * calling this method, and to call {@link PriorityTaskManager#remove} afterwards to unregister. - * - *

    To cancel the operation, the caller should both set {@code isCanceled} to true and interrupt - * the calling thread. - * - *

    This method may be slow and shouldn't normally be called on the main thread. - * - * @param dataSource A {@link CacheDataSource} to be used for caching the data. - * @param dataSpec Defines the data to be cached. - * @param progressListener A listener to receive progress updates, or {@code null}. - * @param isCanceled An optional flag that will cancel the operation if set to true. - * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been - * reached unexpectedly. - * @param temporaryBuffer A temporary buffer to be used during caching. - * @throws IOException If an error occurs caching the data, or if the operation was canceled. - */ - @WorkerThread - public static void cache( - CacheDataSource dataSource, - DataSpec dataSpec, - @Nullable ProgressListener progressListener, - @Nullable AtomicBoolean isCanceled, - boolean enableEOFException, - byte[] temporaryBuffer) - throws IOException { - Assertions.checkNotNull(dataSource); - Assertions.checkNotNull(temporaryBuffer); - - Cache cache = dataSource.getCache(); - String cacheKey = dataSource.getCacheKeyFactory().buildCacheKey(dataSpec); - long requestLength = dataSpec.length; - if (requestLength == C.LENGTH_UNSET) { - long resourceLength = ContentMetadata.getContentLength(cache.getContentMetadata(cacheKey)); - if (resourceLength != C.LENGTH_UNSET) { - requestLength = resourceLength - dataSpec.position; - } - } - long bytesCached = cache.getCachedBytes(cacheKey, dataSpec.position, requestLength); - @Nullable ProgressNotifier progressNotifier = null; - if (progressListener != null) { - progressNotifier = new ProgressNotifier(progressListener); - progressNotifier.init(requestLength, bytesCached); - } - - long position = dataSpec.position; - long bytesLeft = requestLength; - while (bytesLeft != 0) { - throwExceptionIfCanceled(isCanceled); - long blockLength = cache.getCachedLength(cacheKey, position, bytesLeft); - if (blockLength > 0) { - // Skip already cached data. - } else { - // There is a hole in the cache which is at least "-blockLength" long. - blockLength = -blockLength; - long length = blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength; - boolean isLastBlock = length == bytesLeft; - long read = - readAndDiscard( - dataSpec, - position, - length, - dataSource, - isCanceled, - progressNotifier, - isLastBlock, - temporaryBuffer); - if (read < blockLength) { - // Reached to the end of the data. - if (enableEOFException && bytesLeft != C.LENGTH_UNSET) { - throw new EOFException(); - } - break; - } - } - position += blockLength; - if (bytesLeft != C.LENGTH_UNSET) { - bytesLeft -= blockLength; - } - } - } - - /** - * Reads and discards all data specified by the {@code dataSpec}. - * - * @param dataSpec Defines the data to be read. The {@code position} and {@code length} fields are - * overwritten by the following parameters. - * @param position The position of the data to be read. - * @param length Length of the data to be read, or {@link C#LENGTH_UNSET} if it is unknown. - * @param dataSource The {@link CacheDataSource} to read the data from. - * @param isCanceled An optional flag that will cancel the operation if set to true. - * @param progressNotifier A notifier through which to report progress updates, or {@code null}. - * @param isLastBlock Whether this read block is the last block of the content. - * @param temporaryBuffer A temporary buffer to be used during caching. - * @return Number of read bytes, or 0 if no data is available because the end of the opened range - * has been reached. - * @param isCanceled An optional flag that will cancel the operation if set to true. - */ - private static long readAndDiscard( - DataSpec dataSpec, - long position, - long length, - CacheDataSource dataSource, - @Nullable AtomicBoolean isCanceled, - @Nullable ProgressNotifier progressNotifier, - boolean isLastBlock, - byte[] temporaryBuffer) - throws IOException { - long positionOffset = position - dataSpec.position; - long initialPositionOffset = positionOffset; - long endOffset = length != C.LENGTH_UNSET ? positionOffset + length : C.POSITION_UNSET; - @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager(); - while (true) { - if (priorityTaskManager != null) { - // Wait for any other thread with higher priority to finish its job. - try { - priorityTaskManager.proceed(dataSource.getUpstreamPriority()); - } catch (InterruptedException e) { - throw new InterruptedIOException(); - } - } - throwExceptionIfCanceled(isCanceled); - try { - long resolvedLength = C.LENGTH_UNSET; - boolean isDataSourceOpen = false; - if (endOffset != C.POSITION_UNSET) { - // If a specific length is given, first try to open the data source for that length to - // avoid more data then required to be requested. If the given length exceeds the end of - // input we will get a "position out of range" error. In that case try to open the source - // again with unset length. - try { - resolvedLength = - dataSource.open(dataSpec.subrange(positionOffset, endOffset - positionOffset)); - isDataSourceOpen = true; - } catch (IOException exception) { - if (!isLastBlock || !DataSourceException.isCausedByPositionOutOfRange(exception)) { - throw exception; - } - Util.closeQuietly(dataSource); - } - } - if (!isDataSourceOpen) { - resolvedLength = dataSource.open(dataSpec.subrange(positionOffset, C.LENGTH_UNSET)); - } - if (isLastBlock && progressNotifier != null && resolvedLength != C.LENGTH_UNSET) { - progressNotifier.onRequestLengthResolved(positionOffset + resolvedLength); - } - while (positionOffset != endOffset) { - throwExceptionIfCanceled(isCanceled); - int bytesRead = - dataSource.read( - temporaryBuffer, - 0, - endOffset != C.POSITION_UNSET - ? (int) Math.min(temporaryBuffer.length, endOffset - positionOffset) - : temporaryBuffer.length); - if (bytesRead == C.RESULT_END_OF_INPUT) { - if (progressNotifier != null) { - progressNotifier.onRequestLengthResolved(positionOffset); - } - break; - } - positionOffset += bytesRead; - if (progressNotifier != null) { - progressNotifier.onBytesCached(bytesRead); - } - } - return positionOffset - initialPositionOffset; - } catch (PriorityTaskManager.PriorityTooLowException exception) { - // catch and try again - } finally { - Util.closeQuietly(dataSource); - } - } - } - - private static void throwExceptionIfCanceled(@Nullable AtomicBoolean isCanceled) - throws InterruptedIOException { - if (isCanceled != null && isCanceled.get()) { - throw new InterruptedIOException(); - } - } - - private CacheUtil() {} - - private static final class ProgressNotifier { - /** The listener to notify when progress is made. */ - private final ProgressListener listener; - /** The length of the content being cached in bytes, or {@link C#LENGTH_UNSET} if unknown. */ - private long requestLength; - /** The number of bytes that are cached. */ - private long bytesCached; - - public ProgressNotifier(ProgressListener listener) { - this.listener = listener; - } - - public void init(long requestLength, long bytesCached) { - this.requestLength = requestLength; - this.bytesCached = bytesCached; - listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0); - } - - public void onRequestLengthResolved(long requestLength) { - if (this.requestLength == C.LENGTH_UNSET && requestLength != C.LENGTH_UNSET) { - this.requestLength = requestLength; - listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0); - } - } - - public void onBytesCached(long newBytesCached) { - bytesCached += newBytesCached; - listener.onProgress(requestLength, bytesCached, newBytesCached); - } - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java new file mode 100644 index 0000000000..ee44b0dc51 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.PriorityTaskManager; +import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Caching related utility methods. */ +public final class CacheWriter { + + /** Receives progress updates during cache operations. */ + public interface ProgressListener { + + /** + * Called when progress is made during a cache operation. + * + * @param requestLength The length of the content being cached in bytes, or {@link + * C#LENGTH_UNSET} if unknown. + * @param bytesCached The number of bytes that are cached. + * @param newBytesCached The number of bytes that have been newly cached since the last progress + * update. + */ + void onProgress(long requestLength, long bytesCached, long newBytesCached); + } + + /** Default buffer size to be used while caching. */ + public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024; + + private final CacheDataSource dataSource; + private final Cache cache; + private final DataSpec dataSpec; + private final boolean allowShortContent; + private final AtomicBoolean isCanceled; + private final String cacheKey; + private final byte[] temporaryBuffer; + @Nullable private final ProgressListener progressListener; + + private boolean initialized; + private long nextPosition; + private long endPosition; + private long bytesCached; + + /** + * @param dataSource A {@link CacheDataSource} that writes to the target cache. + * @param dataSpec Defines the data to be written. + * @param allowShortContent Whether it's allowed for the content to end before the request as + * defined by the {@link DataSpec}. If {@code true} and the request exceeds the length of the + * content, then the content will be cached to the end. If {@code false} and the request + * exceeds the length of the content, {@link #cache} will throw an {@link IOException}. + * @param isCanceled An optional cancelation signal. If specified, {@link #cache} will check the + * value of this signal frequently during caching. If the value is {@code true}, the operation + * will be considered canceled and {@link #cache} will throw {@link InterruptedIOException}. + * @param temporaryBuffer A temporary buffer to be used during caching, or {@code null} if the + * writer should instantiate its own internal temporary buffer. + * @param progressListener An optional progress listener. + */ + public CacheWriter( + CacheDataSource dataSource, + DataSpec dataSpec, + boolean allowShortContent, + @Nullable AtomicBoolean isCanceled, + @Nullable byte[] temporaryBuffer, + @Nullable ProgressListener progressListener) { + this.dataSource = dataSource; + this.cache = dataSource.getCache(); + this.dataSpec = dataSpec; + this.allowShortContent = allowShortContent; + this.isCanceled = isCanceled == null ? new AtomicBoolean() : isCanceled; + this.temporaryBuffer = + temporaryBuffer == null ? new byte[DEFAULT_BUFFER_SIZE_BYTES] : temporaryBuffer; + this.progressListener = progressListener; + cacheKey = dataSource.getCacheKeyFactory().buildCacheKey(dataSpec); + nextPosition = dataSpec.position; + } + + /** + * Caches the requested data, skipping any that's already cached. + * + *

    If the {@link CacheDataSource} used by the writer has a {@link PriorityTaskManager}, then + * it's the responsibility of the caller to call {@link PriorityTaskManager#add} to register with + * the manager before calling this method, and to call {@link PriorityTaskManager#remove} + * afterwards to unregister. {@link PriorityTooLowException} will be thrown if the priority + * required by the {@link CacheDataSource} is not high enough for progress to be made. + * + *

    This method may be slow and shouldn't normally be called on the main thread. + * + * @throws IOException If an error occurs reading the data, or writing the data into the cache, or + * if the operation is canceled. If canceled, an {@link InterruptedIOException} is thrown. The + * method may be called again to continue the operation from where the error occurred. + */ + @WorkerThread + public void cache() throws IOException { + throwIfCanceled(); + + if (!initialized) { + if (dataSpec.length != C.LENGTH_UNSET) { + endPosition = dataSpec.position + dataSpec.length; + } else { + long contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(cacheKey)); + endPosition = contentLength == C.LENGTH_UNSET ? C.POSITION_UNSET : contentLength; + } + bytesCached = cache.getCachedBytes(cacheKey, dataSpec.position, dataSpec.length); + if (progressListener != null) { + progressListener.onProgress(getLength(), bytesCached, /* newBytesCached= */ 0); + } + initialized = true; + } + + while (endPosition == C.POSITION_UNSET || nextPosition < endPosition) { + throwIfCanceled(); + long maxRemainingLength = + endPosition == C.POSITION_UNSET ? Long.MAX_VALUE : endPosition - nextPosition; + long blockLength = cache.getCachedLength(cacheKey, nextPosition, maxRemainingLength); + if (blockLength > 0) { + nextPosition += blockLength; + } else { + // There's a hole of length -blockLength. + blockLength = -blockLength; + long nextRequestLength = blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength; + nextPosition += readBlockToCache(nextPosition, nextRequestLength); + } + } + } + + /** + * Reads the specified block of data, writing it into the cache. + * + * @param position The starting position of the block. + * @param length The length of the block, or {@link C#LENGTH_UNSET} if unbounded. + * @return The number of bytes read. + * @throws IOException If an error occurs reading the data or writing it to the cache. + */ + private long readBlockToCache(long position, long length) throws IOException { + boolean isLastBlock = position + length == endPosition || length == C.LENGTH_UNSET; + try { + long resolvedLength = C.LENGTH_UNSET; + boolean isDataSourceOpen = false; + if (length != C.LENGTH_UNSET) { + // If the length is specified, try to open the data source with a bounded request to avoid + // the underlying network stack requesting more data than required. + try { + DataSpec boundedDataSpec = + dataSpec.buildUpon().setPosition(position).setLength(length).build(); + resolvedLength = dataSource.open(boundedDataSpec); + isDataSourceOpen = true; + } catch (IOException exception) { + if (allowShortContent + && isLastBlock + && DataSourceException.isCausedByPositionOutOfRange(exception)) { + // The length of the request exceeds the length of the content. If we allow shorter + // content and are reading the last block, fall through and try again with an unbounded + // request to read up to the end of the content. + Util.closeQuietly(dataSource); + } else { + throw exception; + } + } + } + if (!isDataSourceOpen) { + // Either the length was unspecified, or we allow short content and our attempt to open the + // DataSource with the specified length failed. + throwIfCanceled(); + DataSpec unboundedDataSpec = + dataSpec.buildUpon().setPosition(position).setLength(C.LENGTH_UNSET).build(); + resolvedLength = dataSource.open(unboundedDataSpec); + } + if (isLastBlock && resolvedLength != C.LENGTH_UNSET) { + onRequestEndPosition(position + resolvedLength); + } + int totalBytesRead = 0; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT) { + throwIfCanceled(); + bytesRead = dataSource.read(temporaryBuffer, /* offset= */ 0, temporaryBuffer.length); + if (bytesRead != C.RESULT_END_OF_INPUT) { + onNewBytesCached(bytesRead); + totalBytesRead += bytesRead; + } + } + if (isLastBlock) { + onRequestEndPosition(position + totalBytesRead); + } + return totalBytesRead; + } finally { + Util.closeQuietly(dataSource); + } + } + + private void onRequestEndPosition(long endPosition) { + if (this.endPosition == endPosition) { + return; + } + this.endPosition = endPosition; + if (progressListener != null) { + progressListener.onProgress(getLength(), bytesCached, /* newBytesCached= */ 0); + } + } + + private void onNewBytesCached(long newBytesCached) { + bytesCached += newBytesCached; + if (progressListener != null) { + progressListener.onProgress(getLength(), bytesCached, newBytesCached); + } + } + + private long getLength() { + return endPosition == C.POSITION_UNSET ? C.LENGTH_UNSET : endPosition - dataSpec.position; + } + + private void throwIfCanceled() throws InterruptedIOException { + if (isCanceled.get()) { + throw new InterruptedIOException(); + } + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index b4c259689e..d8a7a03406 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -357,8 +357,15 @@ public final class CacheDataSourceTest { .newDefaultData() .appendReadData(1024 * 1024) .endData()); - CacheUtil.cache( - cache, unboundedDataSpec, upstream2, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream2), + unboundedDataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Read the rest of the data. TestUtil.readToEnd(cacheDataSource); @@ -401,8 +408,15 @@ public final class CacheDataSourceTest { .newDefaultData() .appendReadData(1024 * 1024) .endData()); - CacheUtil.cache( - cache, unboundedDataSpec, upstream2, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream2), + unboundedDataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Read the rest of the data. TestUtil.readToEnd(cacheDataSource); @@ -420,8 +434,15 @@ public final class CacheDataSourceTest { // Cache the latter half of the data. int halfDataLength = 512; DataSpec dataSpec = buildDataSpec(halfDataLength, C.LENGTH_UNSET); - CacheUtil.cache( - cache, dataSpec, upstream, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Create cache read-only CacheDataSource. CacheDataSource cacheDataSource = @@ -451,8 +472,15 @@ public final class CacheDataSourceTest { // Cache the latter half of the data. int halfDataLength = 512; DataSpec dataSpec = buildDataSpec(/* position= */ 0, halfDataLength); - CacheUtil.cache( - cache, dataSpec, upstream, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Create blocking CacheDataSource. CacheDataSource cacheDataSource = diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java similarity index 71% rename from library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java index cc7a232b3f..0e97756552 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java @@ -26,10 +26,11 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.FakeDataSet; import com.google.android.exoplayer2.testutil.FakeDataSource; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Util; -import java.io.EOFException; import java.io.File; +import java.io.IOException; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -38,9 +39,9 @@ import org.mockito.Answers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -/** Tests {@link CacheUtil}. */ +/** Unit tests for {@link CacheWriter}. */ @RunWith(AndroidJUnit4.class) -public final class CacheUtilTest { +public final class CacheWriterTest { /** * Abstract fake Cache implementation used by the test. This class must be public so Mockito can @@ -109,8 +110,16 @@ public final class CacheUtilTest { FakeDataSource dataSource = new FakeDataSource(fakeDataSet); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - cache, new DataSpec(Uri.parse("test_data")), dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(Uri.parse("test_data")), + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); @@ -124,12 +133,29 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 20, 20); counters.reset(); - CacheUtil.cache(cache, new DataSpec(testUri), dataSource, counters, /* isCanceled= */ null); + cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(testUri), + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(20, 80, 100); assertCachedData(cache, fakeDataSet); @@ -144,7 +170,16 @@ public final class CacheUtilTest { DataSpec dataSpec = new DataSpec(Uri.parse("test_data")); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); @@ -160,12 +195,29 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 20, 20); counters.reset(); - CacheUtil.cache(cache, new DataSpec(testUri), dataSource, counters, /* isCanceled= */ null); + cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(testUri), + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(20, 80, 100); assertCachedData(cache, fakeDataSet); @@ -179,9 +231,18 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); - counters.assertValues(0, 100, 1000); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ true, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); + + counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); } @@ -194,16 +255,18 @@ public final class CacheUtilTest { DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); try { - CacheUtil.cache( - new CacheDataSource(cache, dataSource), - dataSpec, - /* progressListener= */ null, - /* isCanceled= */ null, - /* enableEOFException= */ true, - /* temporaryBuffer= */ new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES]); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); fail(); - } catch (EOFException e) { - // Do nothing. + } catch (IOException e) { + assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue(); } } @@ -221,14 +284,21 @@ public final class CacheUtilTest { .endData(); FakeDataSource dataSource = new FakeDataSource(fakeDataSet); - CacheUtil.cache( - cache, new DataSpec(Uri.parse("test_data")), dataSource, counters, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(Uri.parse("test_data")), + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 300, 300); assertCachedData(cache, fakeDataSet); } - private static final class CachingCounters implements CacheUtil.ProgressListener { + private static final class CachingCounters implements CacheWriter.ProgressListener { private long contentLength = C.LENGTH_UNSET; private long bytesAlreadyCached; From 32c356177f2c4d420293e9c6e6abcee63c2cccbe Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 27 May 2020 12:57:11 +0100 Subject: [PATCH 0306/1052] Extract a ChunkExtractor interface A future implementation will depend on MediaParser. PiperOrigin-RevId: 313367998 --- .../source/chunk/BaseMediaChunkOutput.java | 2 +- ...rapper.java => BundledChunkExtractor.java} | 70 +++++---------- .../source/chunk/ChunkExtractor.java | 86 +++++++++++++++++++ .../source/chunk/ContainerMediaChunk.java | 18 ++-- .../source/chunk/InitializationChunk.java | 18 ++-- .../exoplayer2/source/dash/DashUtil.java | 48 ++++++----- .../source/dash/DefaultDashChunkSource.java | 45 ++++++---- .../smoothstreaming/DefaultSsChunkSource.java | 19 ++-- 8 files changed, 187 insertions(+), 119 deletions(-) rename library/core/src/main/java/com/google/android/exoplayer2/source/chunk/{ChunkExtractorWrapper.java => BundledChunkExtractor.java} (75%) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java index 50c37f8b31..961d1f8db6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java @@ -18,7 +18,7 @@ package com.google.android.exoplayer2.source.chunk; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.source.SampleQueue; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.util.Log; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java similarity index 75% rename from library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java rename to library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java index f2362f2eb1..6038ad2040 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java @@ -23,7 +23,9 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.upstream.DataReader; @@ -33,34 +35,14 @@ import java.io.IOException; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** - * An {@link Extractor} wrapper for loading chunks that contain a single primary track, and possibly - * additional embedded tracks. - *

    - * The wrapper allows switching of the {@link TrackOutput}s that receive parsed data. + * {@link ChunkExtractor} implementation that uses ExoPlayer app-bundled {@link Extractor + * Extractors}. */ -public final class ChunkExtractorWrapper implements ExtractorOutput { +public final class BundledChunkExtractor implements ExtractorOutput, ChunkExtractor { - /** - * Provides {@link TrackOutput} instances to be written to by the wrapper. - */ - public interface TrackOutputProvider { - - /** - * Called to get the {@link TrackOutput} for a specific track. - *

    - * The same {@link TrackOutput} is returned if multiple calls are made with the same {@code id}. - * - * @param id A track identifier. - * @param type The type of the track. Typically one of the - * {@link com.google.android.exoplayer2.C} {@code TRACK_TYPE_*} constants. - * @return The {@link TrackOutput} for the given track identifier. - */ - TrackOutput track(int id, int type); - - } - - public final Extractor extractor; + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + private final Extractor extractor; private final int primaryTrackType; private final Format primaryTrackManifestFormat; private final SparseArray bindingTrackOutputs; @@ -72,48 +54,37 @@ public final class ChunkExtractorWrapper implements ExtractorOutput { private Format @MonotonicNonNull [] sampleFormats; /** + * Creates an instance. + * * @param extractor The extractor to wrap. - * @param primaryTrackType The type of the primary track. Typically one of the - * {@link com.google.android.exoplayer2.C} {@code TRACK_TYPE_*} constants. + * @param primaryTrackType The type of the primary track. Typically one of the {@link + * com.google.android.exoplayer2.C} {@code TRACK_TYPE_*} constants. * @param primaryTrackManifestFormat A manifest defined {@link Format} whose data should be merged * into any sample {@link Format} output from the {@link Extractor} for the primary track. */ - public ChunkExtractorWrapper(Extractor extractor, int primaryTrackType, - Format primaryTrackManifestFormat) { + public BundledChunkExtractor( + Extractor extractor, int primaryTrackType, Format primaryTrackManifestFormat) { this.extractor = extractor; this.primaryTrackType = primaryTrackType; this.primaryTrackManifestFormat = primaryTrackManifestFormat; bindingTrackOutputs = new SparseArray<>(); } - /** - * Returns the {@link SeekMap} most recently output by the extractor, or null if the extractor has - * not output a {@link SeekMap}. - */ + // ChunkExtractor implementation. + + @Override @Nullable public SeekMap getSeekMap() { return seekMap; } - /** - * Returns the sample {@link Format}s for the tracks identified by the extractor, or null if the - * extractor has not finished identifying tracks. - */ + @Override @Nullable public Format[] getSampleFormats() { return sampleFormats; } - /** - * Initializes the wrapper to output to {@link TrackOutput}s provided by the specified {@link - * TrackOutputProvider}, and configures the extractor to receive data from a new chunk. - * - * @param trackOutputProvider The provider of {@link TrackOutput}s that will receive sample data. - * @param startTimeUs The start position in the new chunk, or {@link C#TIME_UNSET} to output - * samples from the start of the chunk. - * @param endTimeUs The end position in the new chunk, or {@link C#TIME_UNSET} to output samples - * to the end of the chunk. - */ + @Override public void init( @Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs) { this.trackOutputProvider = trackOutputProvider; @@ -132,6 +103,11 @@ public final class ChunkExtractorWrapper implements ExtractorOutput { } } + @Override + public int read(ExtractorInput input) throws IOException { + return extractor.read(input, DUMMY_POSITION_HOLDER); + } + // ExtractorOutput implementation. @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java new file mode 100644 index 0000000000..ee4daea725 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.extractor.TrackOutput; +import java.io.IOException; + +/** + * Extracts samples and track {@link Format Formats} from chunks. + * + *

    The {@link TrackOutputProvider} passed to {@link #init} provides the {@link TrackOutput + * TrackOutputs} that receive the extracted data. + */ +public interface ChunkExtractor { + + /** Provides {@link TrackOutput} instances to be written to during extraction. */ + interface TrackOutputProvider { + + /** + * Called to get the {@link TrackOutput} for a specific track. + * + *

    The same {@link TrackOutput} is returned if multiple calls are made with the same {@code + * id}. + * + * @param id A track identifier. + * @param type The type of the track. Typically one of the {@link C} {@code TRACK_TYPE_*} + * constants. + * @return The {@link TrackOutput} for the given track identifier. + */ + TrackOutput track(int id, int type); + } + + /** + * Returns the {@link SeekMap} most recently output by the extractor, or null if the extractor has + * not output a {@link SeekMap}. + */ + @Nullable + SeekMap getSeekMap(); + + /** + * Returns the sample {@link Format}s for the tracks identified by the extractor, or null if the + * extractor has not finished identifying tracks. + */ + @Nullable + Format[] getSampleFormats(); + + /** + * Initializes the wrapper to output to {@link TrackOutput}s provided by the specified {@link + * TrackOutputProvider}, and configures the extractor to receive data from a new chunk. + * + * @param trackOutputProvider The provider of {@link TrackOutput}s that will receive sample data. + * @param startTimeUs The start position in the new chunk, or {@link C#TIME_UNSET} to output + * samples from the start of the chunk. + * @param endTimeUs The end position in the new chunk, or {@link C#TIME_UNSET} to output samples + * to the end of the chunk. + */ + void init(@Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs); + + /** + * Reads from the given {@link ExtractorInput}. + * + * @param input The input to read from. + * @return One of the {@link Extractor}{@code .RESULT_*} values. + * @throws IOException If an error occurred reading from or parsing the input. + */ + int read(ExtractorInput input) throws IOException; +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 1b43af2084..d0daaf0839 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -21,8 +21,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.PositionHolder; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Assertions; @@ -34,11 +33,9 @@ import java.io.IOException; */ public class ContainerMediaChunk extends BaseMediaChunk { - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); - private final int chunkCount; private final long sampleOffsetUs; - private final ChunkExtractorWrapper extractorWrapper; + private final ChunkExtractor chunkExtractor; private long nextLoadPosition; private volatile boolean loadCanceled; @@ -61,7 +58,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { * instance. Normally equal to one, but may be larger if multiple chunks as defined by the * underlying media are being merged into a single load. * @param sampleOffsetUs An offset to add to the sample timestamps parsed by the extractor. - * @param extractorWrapper A wrapped extractor to use for parsing the data. + * @param chunkExtractor A wrapped extractor to use for parsing the data. */ public ContainerMediaChunk( DataSource dataSource, @@ -76,7 +73,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { long chunkIndex, int chunkCount, long sampleOffsetUs, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { super( dataSource, dataSpec, @@ -90,7 +87,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { chunkIndex); this.chunkCount = chunkCount; this.sampleOffsetUs = sampleOffsetUs; - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; } @Override @@ -117,7 +114,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { // Configure the output and set it as the target for the extractor wrapper. BaseMediaChunkOutput output = getOutput(); output.setSampleOffsetUs(sampleOffsetUs); - extractorWrapper.init( + chunkExtractor.init( getTrackOutputProvider(output), clippedStartTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedStartTimeUs - sampleOffsetUs), clippedEndTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedEndTimeUs - sampleOffsetUs)); @@ -130,10 +127,9 @@ public class ContainerMediaChunk extends BaseMediaChunk { dataSource, loadDataSpec.position, dataSource.open(loadDataSpec)); // Load and decode the sample data. try { - Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, DUMMY_POSITION_HOLDER); + result = chunkExtractor.read(input); } Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index 8b954af2f8..fb33e940f2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -21,8 +21,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.PositionHolder; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Assertions; @@ -35,9 +34,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public final class InitializationChunk extends Chunk { - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); - - private final ChunkExtractorWrapper extractorWrapper; + private final ChunkExtractor chunkExtractor; private @MonotonicNonNull TrackOutputProvider trackOutputProvider; private long nextLoadPosition; @@ -49,7 +46,7 @@ public final class InitializationChunk extends Chunk { * @param trackFormat See {@link #trackFormat}. * @param trackSelectionReason See {@link #trackSelectionReason}. * @param trackSelectionData See {@link #trackSelectionData}. - * @param extractorWrapper A wrapped extractor to use for parsing the initialization data. + * @param chunkExtractor A wrapped extractor to use for parsing the initialization data. */ public InitializationChunk( DataSource dataSource, @@ -57,10 +54,10 @@ public final class InitializationChunk extends Chunk { Format trackFormat, int trackSelectionReason, @Nullable Object trackSelectionData, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { super(dataSource, dataSpec, C.DATA_TYPE_MEDIA_INITIALIZATION, trackFormat, trackSelectionReason, trackSelectionData, C.TIME_UNSET, C.TIME_UNSET); - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; } /** @@ -85,7 +82,7 @@ public final class InitializationChunk extends Chunk { @Override public void load() throws IOException { if (nextLoadPosition == 0) { - extractorWrapper.init( + chunkExtractor.init( trackOutputProvider, /* startTimeUs= */ C.TIME_UNSET, /* endTimeUs= */ C.TIME_UNSET); } try { @@ -96,10 +93,9 @@ public final class InitializationChunk extends Chunk { dataSource, loadDataSpec.position, dataSource.open(loadDataSpec)); // Load and decode the initialization data. try { - Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, DUMMY_POSITION_HOLDER); + result = chunkExtractor.read(input); } Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java index 6d440b96df..474902dd5b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java @@ -24,7 +24,8 @@ import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor; import com.google.android.exoplayer2.source.chunk.InitializationChunk; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; @@ -113,11 +114,11 @@ public final class DashUtil { @Nullable public static Format loadSampleFormat( DataSource dataSource, int trackType, Representation representation) throws IOException { - ChunkExtractorWrapper extractorWrapper = loadInitializationData(dataSource, trackType, - representation, false); - return extractorWrapper == null + ChunkExtractor chunkExtractor = + loadInitializationData(dataSource, trackType, representation, false); + return chunkExtractor == null ? null - : Assertions.checkStateNotNull(extractorWrapper.getSampleFormats())[0]; + : Assertions.checkStateNotNull(chunkExtractor.getSampleFormats())[0]; } /** @@ -135,33 +136,33 @@ public final class DashUtil { @Nullable public static ChunkIndex loadChunkIndex( DataSource dataSource, int trackType, Representation representation) throws IOException { - ChunkExtractorWrapper extractorWrapper = loadInitializationData(dataSource, trackType, - representation, true); - return extractorWrapper == null ? null : (ChunkIndex) extractorWrapper.getSeekMap(); + ChunkExtractor chunkExtractor = + loadInitializationData(dataSource, trackType, representation, true); + return chunkExtractor == null ? null : (ChunkIndex) chunkExtractor.getSeekMap(); } /** * Loads initialization data for the {@code representation} and optionally index data then returns - * a {@link ChunkExtractorWrapper} which contains the output. + * a {@link BundledChunkExtractor} which contains the output. * * @param dataSource The source from which the data should be loaded. * @param trackType The type of the representation. Typically one of the {@link * com.google.android.exoplayer2.C} {@code TRACK_TYPE_*} constants. * @param representation The representation which initialization chunk belongs to. * @param loadIndex Whether to load index data too. - * @return A {@link ChunkExtractorWrapper} for the {@code representation}, or null if no + * @return A {@link BundledChunkExtractor} for the {@code representation}, or null if no * initialization or (if requested) index data exists. * @throws IOException Thrown when there is an error while loading. */ @Nullable - private static ChunkExtractorWrapper loadInitializationData( + private static ChunkExtractor loadInitializationData( DataSource dataSource, int trackType, Representation representation, boolean loadIndex) throws IOException { RangedUri initializationUri = representation.getInitializationUri(); if (initializationUri == null) { return null; } - ChunkExtractorWrapper extractorWrapper = newWrappedExtractor(trackType, representation.format); + ChunkExtractor chunkExtractor = newChunkExtractor(trackType, representation.format); RangedUri requestUri; if (loadIndex) { RangedUri indexUri = representation.getIndexUri(); @@ -172,37 +173,42 @@ public final class DashUtil { // the two requests together to request both at once. requestUri = initializationUri.attemptMerge(indexUri, representation.baseUrl); if (requestUri == null) { - loadInitializationData(dataSource, representation, extractorWrapper, initializationUri); + loadInitializationData(dataSource, representation, chunkExtractor, initializationUri); requestUri = indexUri; } } else { requestUri = initializationUri; } - loadInitializationData(dataSource, representation, extractorWrapper, requestUri); - return extractorWrapper; + loadInitializationData(dataSource, representation, chunkExtractor, requestUri); + return chunkExtractor; } private static void loadInitializationData( DataSource dataSource, Representation representation, - ChunkExtractorWrapper extractorWrapper, + ChunkExtractor chunkExtractor, RangedUri requestUri) throws IOException { DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); - InitializationChunk initializationChunk = new InitializationChunk(dataSource, dataSpec, - representation.format, C.SELECTION_REASON_UNKNOWN, null /* trackSelectionData */, - extractorWrapper); + InitializationChunk initializationChunk = + new InitializationChunk( + dataSource, + dataSpec, + representation.format, + C.SELECTION_REASON_UNKNOWN, + null /* trackSelectionData */, + chunkExtractor); initializationChunk.load(); } - private static ChunkExtractorWrapper newWrappedExtractor(int trackType, Format format) { + private static ChunkExtractor newChunkExtractor(int trackType, Format format) { String mimeType = format.containerMimeType; boolean isWebm = mimeType != null && (mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM)); Extractor extractor = isWebm ? new MatroskaExtractor() : new FragmentedMp4Extractor(); - return new ChunkExtractorWrapper(extractor, trackType, format); + return new BundledChunkExtractor(extractor, trackType, format); } @Nullable diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index e03ade2d48..29f62f0aca 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -31,8 +31,9 @@ import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer2.extractor.rawcc.RawCcExtractor; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; import com.google.android.exoplayer2.source.chunk.Chunk; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor; import com.google.android.exoplayer2.source.chunk.ChunkHolder; import com.google.android.exoplayer2.source.chunk.ContainerMediaChunk; import com.google.android.exoplayer2.source.chunk.InitializationChunk; @@ -302,11 +303,11 @@ public class DefaultDashChunkSource implements DashChunkSource { RepresentationHolder representationHolder = representationHolders[trackSelection.getSelectedIndex()]; - if (representationHolder.extractorWrapper != null) { + if (representationHolder.chunkExtractor != null) { Representation selectedRepresentation = representationHolder.representation; RangedUri pendingInitializationUri = null; RangedUri pendingIndexUri = null; - if (representationHolder.extractorWrapper.getSampleFormats() == null) { + if (representationHolder.chunkExtractor.getSampleFormats() == null) { pendingInitializationUri = selectedRepresentation.getInitializationUri(); } if (representationHolder.segmentIndex == null) { @@ -399,7 +400,7 @@ public class DefaultDashChunkSource implements DashChunkSource { // from the stream. If the manifest defines an index then the stream shouldn't, but in cases // where it does we should ignore it. if (representationHolder.segmentIndex == null) { - SeekMap seekMap = representationHolder.extractorWrapper.getSeekMap(); + SeekMap seekMap = representationHolder.chunkExtractor.getSeekMap(); if (seekMap != null) { representationHolders[trackIndex] = representationHolder.copyWithNewSegmentIndex( @@ -500,8 +501,13 @@ public class DefaultDashChunkSource implements DashChunkSource { requestUri = indexUri; } DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); - return new InitializationChunk(dataSource, dataSpec, trackFormat, - trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper); + return new InitializationChunk( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + representationHolder.chunkExtractor); } protected Chunk newMediaChunk( @@ -518,7 +524,7 @@ public class DefaultDashChunkSource implements DashChunkSource { long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); String baseUrl = representation.baseUrl; - if (representationHolder.extractorWrapper == null) { + if (representationHolder.chunkExtractor == null) { long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri); return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, @@ -556,7 +562,7 @@ public class DefaultDashChunkSource implements DashChunkSource { firstSegmentNum, segmentCount, sampleOffsetUs, - representationHolder.extractorWrapper); + representationHolder.chunkExtractor); } } @@ -605,7 +611,7 @@ public class DefaultDashChunkSource implements DashChunkSource { /** Holds information about a snapshot of a single {@link Representation}. */ protected static final class RepresentationHolder { - /* package */ final @Nullable ChunkExtractorWrapper extractorWrapper; + @Nullable /* package */ final ChunkExtractor chunkExtractor; public final Representation representation; @Nullable public final DashSegmentIndex segmentIndex; @@ -623,7 +629,7 @@ public class DefaultDashChunkSource implements DashChunkSource { this( periodDurationUs, representation, - createExtractorWrapper( + createChunkExtractor( trackType, representation, enableEventMessageTrack, @@ -636,13 +642,13 @@ public class DefaultDashChunkSource implements DashChunkSource { private RepresentationHolder( long periodDurationUs, Representation representation, - @Nullable ChunkExtractorWrapper extractorWrapper, + @Nullable ChunkExtractor chunkExtractor, long segmentNumShift, @Nullable DashSegmentIndex segmentIndex) { this.periodDurationUs = periodDurationUs; this.representation = representation; this.segmentNumShift = segmentNumShift; - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; this.segmentIndex = segmentIndex; } @@ -656,20 +662,20 @@ public class DefaultDashChunkSource implements DashChunkSource { if (oldIndex == null) { // Segment numbers cannot shift if the index isn't defined by the manifest. return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, oldIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, segmentNumShift, oldIndex); } if (!oldIndex.isExplicit()) { // Segment numbers cannot shift if the index isn't explicit. return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, segmentNumShift, newIndex); } int oldIndexSegmentCount = oldIndex.getSegmentCount(newPeriodDurationUs); if (oldIndexSegmentCount == 0) { // Segment numbers cannot shift if the old index was empty. return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, segmentNumShift, newIndex); } long oldIndexFirstSegmentNum = oldIndex.getFirstSegmentNum(); @@ -701,13 +707,13 @@ public class DefaultDashChunkSource implements DashChunkSource { - newIndexFirstSegmentNum; } return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, newSegmentNumShift, newIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, newSegmentNumShift, newIndex); } @CheckResult /* package */ RepresentationHolder copyWithNewSegmentIndex(DashSegmentIndex segmentIndex) { return new RepresentationHolder( - periodDurationUs, representation, extractorWrapper, segmentNumShift, segmentIndex); + periodDurationUs, representation, chunkExtractor, segmentNumShift, segmentIndex); } public long getFirstSegmentNum() { @@ -772,7 +778,8 @@ public class DefaultDashChunkSource implements DashChunkSource { || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); } - private static @Nullable ChunkExtractorWrapper createExtractorWrapper( + @Nullable + private static ChunkExtractor createChunkExtractor( int trackType, Representation representation, boolean enableEventMessageTrack, @@ -803,7 +810,7 @@ public class DefaultDashChunkSource implements DashChunkSource { closedCaptionFormats, playerEmsgTrackOutput); } - return new ChunkExtractorWrapper(extractor, trackType, representation.format); + return new BundledChunkExtractor(extractor, trackType, representation.format); } } } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java index 5ce2e6a1c5..10e725fb58 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java @@ -25,8 +25,9 @@ import com.google.android.exoplayer2.extractor.mp4.Track; import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; import com.google.android.exoplayer2.source.chunk.Chunk; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor; import com.google.android.exoplayer2.source.chunk.ChunkHolder; import com.google.android.exoplayer2.source.chunk.ContainerMediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunk; @@ -74,7 +75,7 @@ public class DefaultSsChunkSource implements SsChunkSource { private final LoaderErrorThrower manifestLoaderErrorThrower; private final int streamElementIndex; - private final ChunkExtractorWrapper[] extractorWrappers; + private final ChunkExtractor[] chunkExtractors; private final DataSource dataSource; private TrackSelection trackSelection; @@ -103,8 +104,8 @@ public class DefaultSsChunkSource implements SsChunkSource { this.dataSource = dataSource; StreamElement streamElement = manifest.streamElements[streamElementIndex]; - extractorWrappers = new ChunkExtractorWrapper[trackSelection.length()]; - for (int i = 0; i < extractorWrappers.length; i++) { + chunkExtractors = new ChunkExtractor[trackSelection.length()]; + for (int i = 0; i < chunkExtractors.length; i++) { int manifestTrackIndex = trackSelection.getIndexInTrackGroup(i); Format format = streamElement.formats[manifestTrackIndex]; @Nullable @@ -122,7 +123,7 @@ public class DefaultSsChunkSource implements SsChunkSource { | FragmentedMp4Extractor.FLAG_WORKAROUND_IGNORE_TFDT_BOX, /* timestampAdjuster= */ null, track); - extractorWrappers[i] = new ChunkExtractorWrapper(extractor, streamElement.type, format); + chunkExtractors[i] = new BundledChunkExtractor(extractor, streamElement.type, format); } } @@ -238,7 +239,7 @@ public class DefaultSsChunkSource implements SsChunkSource { int currentAbsoluteChunkIndex = chunkIndex + currentManifestChunkOffset; int trackSelectionIndex = trackSelection.getSelectedIndex(); - ChunkExtractorWrapper extractorWrapper = extractorWrappers[trackSelectionIndex]; + ChunkExtractor chunkExtractor = chunkExtractors[trackSelectionIndex]; int manifestTrackIndex = trackSelection.getIndexInTrackGroup(trackSelectionIndex); Uri uri = streamElement.buildRequestUri(manifestTrackIndex, chunkIndex); @@ -254,7 +255,7 @@ public class DefaultSsChunkSource implements SsChunkSource { chunkSeekTimeUs, trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - extractorWrapper); + chunkExtractor); } @Override @@ -282,7 +283,7 @@ public class DefaultSsChunkSource implements SsChunkSource { long chunkSeekTimeUs, int trackSelectionReason, @Nullable Object trackSelectionData, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { DataSpec dataSpec = new DataSpec(uri); // In SmoothStreaming each chunk contains sample timestamps relative to the start of the chunk. // To convert them the absolute timestamps, we need to set sampleOffsetUs to chunkStartTimeUs. @@ -300,7 +301,7 @@ public class DefaultSsChunkSource implements SsChunkSource { chunkIndex, /* chunkCount= */ 1, sampleOffsetUs, - extractorWrapper); + chunkExtractor); } private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { From f84bd4635a64dd0705bc69dbfc41898edd0cfb10 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 27 May 2020 14:10:47 +0100 Subject: [PATCH 0307/1052] Replace manifest uri without changing the media item We aim to have the same media item instance published in the timeline that is passed to the createMediaSource(mediaItem) method. This means when the manifest uri is manually replaced by a developer, the media item published in the next timeline update is still the same. Note: This leads to the fact that the manifest published in that timeline is loaded from a different URI than the URI of the media item in the same timeline. PiperOrigin-RevId: 313375421 --- .../source/dash/DashMediaSource.java | 12 ++++----- .../source/dash/DashMediaSourceTest.java | 27 +++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 03d967e895..e2954fb6ff 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.source.dash; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Util.castNonNull; import android.net.Uri; import android.os.Handler; @@ -453,6 +452,8 @@ public final class DashMediaSource extends BaseMediaSource { private final Runnable simulateManifestRefreshRunnable; private final PlayerEmsgCallback playerEmsgCallback; private final LoaderErrorThrower manifestLoadErrorThrower; + private final MediaItem mediaItem; + private final MediaItem.PlaybackProperties playbackProperties; private DataSource dataSource; private Loader loader; @@ -461,9 +462,8 @@ public final class DashMediaSource extends BaseMediaSource { private IOException manifestFatalError; private Handler handler; - private MediaItem mediaItem; - private MediaItem.PlaybackProperties playbackProperties; private Uri manifestUri; + private Uri initialManifestUri; private DashManifest manifest; private boolean manifestLoadPending; private long manifestLoadStartTimestampMs; @@ -604,6 +604,7 @@ public final class DashMediaSource extends BaseMediaSource { this.mediaItem = mediaItem; this.playbackProperties = checkNotNull(mediaItem.playbackProperties); this.manifestUri = playbackProperties.uri; + this.initialManifestUri = playbackProperties.uri; this.manifest = manifest; this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; @@ -642,8 +643,7 @@ public final class DashMediaSource extends BaseMediaSource { public void replaceManifestUri(Uri manifestUri) { synchronized (manifestUriLock) { this.manifestUri = manifestUri; - this.mediaItem = mediaItem.buildUpon().setUri(manifestUri).build(); - this.playbackProperties = castNonNull(mediaItem.playbackProperties); + this.initialManifestUri = manifestUri; } } @@ -722,7 +722,7 @@ public final class DashMediaSource extends BaseMediaSource { manifestLoadStartTimestampMs = 0; manifestLoadEndTimestampMs = 0; manifest = sideloadedManifest ? manifest : null; - manifestUri = playbackProperties.uri; + manifestUri = initialManifestUri; manifestFatalError = null; if (handler != null) { handler.removeCallbacksAndMessages(null); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java index 9771d09370..aa65237095 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java @@ -17,14 +17,14 @@ package com.google.android.exoplayer2.source.dash; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; -import static org.mockito.Mockito.mock; +import android.net.Uri; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.offline.StreamKey; -import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableList; @@ -81,7 +81,7 @@ public final class DashMediaSourceTest { Object tag = new Object(); MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); DashMediaSource.Factory factory = - new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); @@ -99,7 +99,7 @@ public final class DashMediaSourceTest { MediaItem mediaItem = new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); DashMediaSource.Factory factory = - new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(factoryTag); + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(factoryTag); MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); @@ -115,7 +115,7 @@ public final class DashMediaSourceTest { Object tag = new Object(); MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); DashMediaSource.Factory factory = - new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); @@ -130,7 +130,7 @@ public final class DashMediaSourceTest { MediaItem mediaItem = new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); DashMediaSource.Factory factory = - new DashMediaSource.Factory(mock(DataSource.Factory.class)).setTag(new Object()); + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(new Object()); @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); @@ -144,7 +144,7 @@ public final class DashMediaSourceTest { MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); DashMediaSource.Factory factory = - new DashMediaSource.Factory(mock(DataSource.Factory.class)) + new DashMediaSource.Factory(new FileDataSource.Factory()) .setStreamKeys(ImmutableList.of(streamKey)); MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); @@ -165,7 +165,7 @@ public final class DashMediaSourceTest { .setStreamKeys(ImmutableList.of(mediaItemStreamKey)) .build(); DashMediaSource.Factory factory = - new DashMediaSource.Factory(mock(DataSource.Factory.class)) + new DashMediaSource.Factory(new FileDataSource.Factory()) .setStreamKeys( ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); @@ -176,6 +176,17 @@ public final class DashMediaSourceTest { assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); } + @Test + public void replaceManifestUri_doesNotChangeMediaItem() { + DashMediaSource.Factory factory = new DashMediaSource.Factory(new FileDataSource.Factory()); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + DashMediaSource mediaSource = factory.createMediaSource(mediaItem); + + mediaSource.replaceManifestUri(Uri.EMPTY); + + assertThat(mediaSource.getMediaItem()).isEqualTo(mediaItem); + } + private static void assertParseStringToLong( long expected, ParsingLoadable.Parser parser, String data) throws IOException { long actual = parser.parse(null, new ByteArrayInputStream(Util.getUtf8Bytes(data))); From d538d6ae37acbcaf02f7fc452ecd80413b973366 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 14 Apr 2020 15:52:18 +0100 Subject: [PATCH 0308/1052] Fix capabilities check for low frame-rate content Issue: #6054 Issue: #474 PiperOrigin-RevId: 306437452 --- .../google/android/exoplayer2/mediacodec/MediaCodecInfo.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java index 64517feec9..60c29f6183 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java @@ -573,7 +573,9 @@ public final class MediaCodecInfo { width = alignedSize.x; height = alignedSize.y; - if (frameRate == Format.NO_VALUE || frameRate <= 0) { + // VideoCapabilities.areSizeAndRateSupported incorrectly returns false if frameRate < 1 on some + // versions of Android, so we only check the size in this case [Internal ref: b/153940404]. + if (frameRate == Format.NO_VALUE || frameRate < 1) { return capabilities.isSizeSupported(width, height); } else { // The signaled frame rate may be slightly higher than the actual frame rate, so we take the From bdc0db30fdddc3cbc01827acdf46b56dfc802bb5 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 14 Apr 2020 21:34:11 +0100 Subject: [PATCH 0309/1052] Don't select trick-play tracks by default Issue: #6054 Issue: #474 PiperOrigin-RevId: 306504362 --- .../src/main/java/com/google/android/exoplayer2/C.java | 8 ++++++-- .../trackselection/DefaultTrackSelector.java | 10 +++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/C.java b/library/core/src/main/java/com/google/android/exoplayer2/C.java index 43cedf985b..3eee0a1891 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/C.java @@ -1056,7 +1056,8 @@ public final class C { * #ROLE_FLAG_DUB}, {@link #ROLE_FLAG_EMERGENCY}, {@link #ROLE_FLAG_CAPTION}, {@link * #ROLE_FLAG_SUBTITLE}, {@link #ROLE_FLAG_SIGN}, {@link #ROLE_FLAG_DESCRIBES_VIDEO}, {@link * #ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND}, {@link #ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY}, - * {@link #ROLE_FLAG_TRANSCRIBES_DIALOG} and {@link #ROLE_FLAG_EASY_TO_READ}. + * {@link #ROLE_FLAG_TRANSCRIBES_DIALOG}, {@link #ROLE_FLAG_EASY_TO_READ} and {@link + * #ROLE_FLAG_TRICK_PLAY}. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -1076,7 +1077,8 @@ public final class C { ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND, ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY, ROLE_FLAG_TRANSCRIBES_DIALOG, - ROLE_FLAG_EASY_TO_READ + ROLE_FLAG_EASY_TO_READ, + ROLE_FLAG_TRICK_PLAY }) public @interface RoleFlags {} /** Indicates a main track. */ @@ -1122,6 +1124,8 @@ public final class C { public static final int ROLE_FLAG_TRANSCRIBES_DIALOG = 1 << 12; /** Indicates the track contains a text that has been edited for ease of reading. */ public static final int ROLE_FLAG_EASY_TO_READ = 1 << 13; + /** Indicates the track is intended for trick play. */ + public static final int ROLE_FLAG_TRICK_PLAY = 1 << 14; /** * Converts a time in microseconds to the corresponding time in milliseconds, preserving diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 822fd03fdf..5330894dab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -1990,6 +1990,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate) { + if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { + // Ignore trick-play tracks for now. + return false; + } return isSupported(formatSupport, false) && ((formatSupport & requiredAdaptiveSupport) != 0) && (mimeType == null || Util.areEqual(format.sampleMimeType, mimeType)) @@ -2013,9 +2017,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { params.viewportWidth, params.viewportHeight, params.viewportOrientationMayChange); @Capabilities int[] trackFormatSupport = formatSupports[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + Format format = trackGroup.getFormat(trackIndex); + if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { + // Ignore trick-play tracks for now. + continue; + } if (isSupported(trackFormatSupport[trackIndex], params.exceedRendererCapabilitiesIfNecessary)) { - Format format = trackGroup.getFormat(trackIndex); boolean isWithinConstraints = selectedTrackIndices.contains(trackIndex) && (format.width == Format.NO_VALUE || format.width <= params.maxVideoWidth) From 2df94913835e7a187e4e08d6f62b4a33169b1803 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 15 Apr 2020 14:33:01 +0100 Subject: [PATCH 0310/1052] Avoid throwing an exception for sample default values Allows playback of content when the default value is not valid, but not used for any samples. Issue: #7207 PiperOrigin-RevId: 306631376 --- RELEASENOTES.md | 6 ++ .../extractor/mp4/FragmentedMp4Extractor.java | 62 +++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8820273a73..3ae572c5b2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,11 @@ # Release notes # +### Next release ### + +* Avoid throwing an exception while parsing fragmented MP4 default sample + values where the most-significant bit is set + ([#7207](https://github.com/google/ExoPlayer/issues/7207)). + ### 2.11.4 (2020-04-08) ### * Add `SimpleExoPlayer.setWakeMode` to allow automatic `WifiLock` and `WakeLock` diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 35f85d0a08..42aeab64b3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -664,9 +664,9 @@ public class FragmentedMp4Extractor implements Extractor { private static Pair parseTrex(ParsableByteArray trex) { trex.setPosition(Atom.FULL_HEADER_SIZE); int trackId = trex.readInt(); - int defaultSampleDescriptionIndex = trex.readUnsignedIntToInt() - 1; - int defaultSampleDuration = trex.readUnsignedIntToInt(); - int defaultSampleSize = trex.readUnsignedIntToInt(); + int defaultSampleDescriptionIndex = trex.readInt() - 1; + int defaultSampleDuration = trex.readInt(); + int defaultSampleSize = trex.readInt(); int defaultSampleFlags = trex.readInt(); return Pair.create(trackId, new DefaultSampleValues(defaultSampleDescriptionIndex, @@ -751,8 +751,9 @@ public class FragmentedMp4Extractor implements Extractor { } } - private static void parseTruns(ContainerAtom traf, TrackBundle trackBundle, long decodeTime, - @Flags int flags) { + private static void parseTruns( + ContainerAtom traf, TrackBundle trackBundle, long decodeTime, @Flags int flags) + throws ParserException { int trunCount = 0; int totalSampleCount = 0; List leafChildren = traf.leafChildren; @@ -871,13 +872,20 @@ public class FragmentedMp4Extractor implements Extractor { DefaultSampleValues defaultSampleValues = trackBundle.defaultSampleValues; int defaultSampleDescriptionIndex = ((atomFlags & 0x02 /* default_sample_description_index_present */) != 0) - ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; - int defaultSampleDuration = ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) - ? tfhd.readUnsignedIntToInt() : defaultSampleValues.duration; - int defaultSampleSize = ((atomFlags & 0x10 /* default_sample_size_present */) != 0) - ? tfhd.readUnsignedIntToInt() : defaultSampleValues.size; - int defaultSampleFlags = ((atomFlags & 0x20 /* default_sample_flags_present */) != 0) - ? tfhd.readUnsignedIntToInt() : defaultSampleValues.flags; + ? tfhd.readInt() - 1 + : defaultSampleValues.sampleDescriptionIndex; + int defaultSampleDuration = + ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) + ? tfhd.readInt() + : defaultSampleValues.duration; + int defaultSampleSize = + ((atomFlags & 0x10 /* default_sample_size_present */) != 0) + ? tfhd.readInt() + : defaultSampleValues.size; + int defaultSampleFlags = + ((atomFlags & 0x20 /* default_sample_flags_present */) != 0) + ? tfhd.readInt() + : defaultSampleValues.flags; trackBundle.fragment.header = new DefaultSampleValues(defaultSampleDescriptionIndex, defaultSampleDuration, defaultSampleSize, defaultSampleFlags); return trackBundle; @@ -910,16 +918,22 @@ public class FragmentedMp4Extractor implements Extractor { /** * Parses a trun atom (defined in 14496-12). * - * @param trackBundle The {@link TrackBundle} that contains the {@link TrackFragment} into - * which parsed data should be placed. + * @param trackBundle The {@link TrackBundle} that contains the {@link TrackFragment} into which + * parsed data should be placed. * @param index Index of the track run in the fragment. * @param decodeTime The decode time of the first sample in the fragment run. * @param flags Flags to allow any required workaround to be executed. * @param trun The trun atom to decode. * @return The starting position of samples for the next run. */ - private static int parseTrun(TrackBundle trackBundle, int index, long decodeTime, - @Flags int flags, ParsableByteArray trun, int trackRunStart) { + private static int parseTrun( + TrackBundle trackBundle, + int index, + long decodeTime, + @Flags int flags, + ParsableByteArray trun, + int trackRunStart) + throws ParserException { trun.setPosition(Atom.HEADER_SIZE); int fullAtom = trun.readInt(); int atomFlags = Atom.parseFullAtomFlags(fullAtom); @@ -937,7 +951,7 @@ public class FragmentedMp4Extractor implements Extractor { boolean firstSampleFlagsPresent = (atomFlags & 0x04 /* first_sample_flags_present */) != 0; int firstSampleFlags = defaultSampleValues.flags; if (firstSampleFlagsPresent) { - firstSampleFlags = trun.readUnsignedIntToInt(); + firstSampleFlags = trun.readInt(); } boolean sampleDurationsPresent = (atomFlags & 0x100 /* sample_duration_present */) != 0; @@ -972,9 +986,10 @@ public class FragmentedMp4Extractor implements Extractor { long cumulativeTime = index > 0 ? fragment.nextFragmentDecodeTime : decodeTime; for (int i = trackRunStart; i < trackRunEnd; i++) { // Use trun values if present, otherwise tfhd, otherwise trex. - int sampleDuration = sampleDurationsPresent ? trun.readUnsignedIntToInt() - : defaultSampleValues.duration; - int sampleSize = sampleSizesPresent ? trun.readUnsignedIntToInt() : defaultSampleValues.size; + int sampleDuration = + checkNonNegative(sampleDurationsPresent ? trun.readInt() : defaultSampleValues.duration); + int sampleSize = + checkNonNegative(sampleSizesPresent ? trun.readInt() : defaultSampleValues.size); int sampleFlags = (i == 0 && firstSampleFlagsPresent) ? firstSampleFlags : sampleFlagsPresent ? trun.readInt() : defaultSampleValues.flags; if (sampleCompositionTimeOffsetsPresent) { @@ -1000,6 +1015,13 @@ public class FragmentedMp4Extractor implements Extractor { return trackRunEnd; } + private static int checkNonNegative(int value) throws ParserException { + if (value < 0) { + throw new ParserException("Unexpected negtive value: " + value); + } + return value; + } + private static void parseUuid(ParsableByteArray uuid, TrackFragment out, byte[] extendedTypeScratch) throws ParserException { uuid.setPosition(Atom.HEADER_SIZE); From 48081dd073e97e792b8a8859340a01f25edcaf6b Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 15 Apr 2020 15:47:10 +0100 Subject: [PATCH 0311/1052] Parse trick-play role flags from DASH manifests Issue: #6054 PiperOrigin-RevId: 306641689 --- .../source/dash/manifest/AdaptationSet.java | 29 +++++------ .../source/dash/manifest/DashManifest.java | 11 +++-- .../dash/manifest/DashManifestParser.java | 49 +++++++++++++++++-- .../src/test/assets/sample_mpd_trick_play | 32 ++++++++++++ .../source/dash/DashMediaPeriodTest.java | 1 + .../exoplayer2/source/dash/DashUtilTest.java | 17 +++++-- .../dash/manifest/DashManifestParserTest.java | 43 +++++++++++++++- .../dash/manifest/DashManifestTest.java | 8 ++- 8 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 library/dash/src/test/assets/sample_mpd_trick_play diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java index d962374745..b0689eeb11 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java @@ -50,9 +50,10 @@ public class AdaptationSet { */ public final List accessibilityDescriptors; - /** - * Supplemental properties in the adaptation set. - */ + /** Essential properties in the adaptation set. */ + public final List essentialProperties; + + /** Supplemental properties in the adaptation set. */ public final List supplementalProperties; /** @@ -62,21 +63,21 @@ public class AdaptationSet { * {@code TRACK_TYPE_*} constants. * @param representations {@link Representation}s in the adaptation set. * @param accessibilityDescriptors Accessibility descriptors in the adaptation set. + * @param essentialProperties Essential properties in the adaptation set. * @param supplementalProperties Supplemental properties in the adaptation set. */ - public AdaptationSet(int id, int type, List representations, - List accessibilityDescriptors, List supplementalProperties) { + public AdaptationSet( + int id, + int type, + List representations, + List accessibilityDescriptors, + List essentialProperties, + List supplementalProperties) { this.id = id; this.type = type; this.representations = Collections.unmodifiableList(representations); - this.accessibilityDescriptors = - accessibilityDescriptors == null - ? Collections.emptyList() - : Collections.unmodifiableList(accessibilityDescriptors); - this.supplementalProperties = - supplementalProperties == null - ? Collections.emptyList() - : Collections.unmodifiableList(supplementalProperties); + this.accessibilityDescriptors = Collections.unmodifiableList(accessibilityDescriptors); + this.essentialProperties = Collections.unmodifiableList(essentialProperties); + this.supplementalProperties = Collections.unmodifiableList(supplementalProperties); } - } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java index 2d8909f8b4..c21af45d15 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java @@ -224,9 +224,14 @@ public class DashManifest implements FilterableManifest { key = keys.poll(); } while (key.periodIndex == periodIndex && key.groupIndex == adaptationSetIndex); - copyAdaptationSets.add(new AdaptationSet(adaptationSet.id, adaptationSet.type, - copyRepresentations, adaptationSet.accessibilityDescriptors, - adaptationSet.supplementalProperties)); + copyAdaptationSets.add( + new AdaptationSet( + adaptationSet.id, + adaptationSet.type, + copyRepresentations, + adaptationSet.accessibilityDescriptors, + adaptationSet.essentialProperties, + adaptationSet.supplementalProperties)); } while(key.periodIndex == periodIndex); // Add back the last key which doesn't belong to the period being processed keys.addFirst(key); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 95129d68c4..6d25c50cf6 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -289,6 +289,7 @@ public class DashManifestParser extends DefaultHandler ArrayList inbandEventStreams = new ArrayList<>(); ArrayList accessibilityDescriptors = new ArrayList<>(); ArrayList roleDescriptors = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(); ArrayList supplementalProperties = new ArrayList<>(); List representationInfos = new ArrayList<>(); @@ -317,6 +318,8 @@ public class DashManifestParser extends DefaultHandler audioChannels = parseAudioChannelConfiguration(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "Accessibility")) { accessibilityDescriptors.add(parseDescriptor(xpp, "Accessibility")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { @@ -334,6 +337,7 @@ public class DashManifestParser extends DefaultHandler language, roleDescriptors, accessibilityDescriptors, + essentialProperties, supplementalProperties, segmentBase, periodDurationMs); @@ -369,14 +373,28 @@ public class DashManifestParser extends DefaultHandler inbandEventStreams)); } - return buildAdaptationSet(id, contentType, representations, accessibilityDescriptors, + return buildAdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, supplementalProperties); } - protected AdaptationSet buildAdaptationSet(int id, int contentType, - List representations, List accessibilityDescriptors, + protected AdaptationSet buildAdaptationSet( + int id, + int contentType, + List representations, + List accessibilityDescriptors, + List essentialProperties, List supplementalProperties) { - return new AdaptationSet(id, contentType, representations, accessibilityDescriptors, + return new AdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, supplementalProperties); } @@ -505,6 +523,7 @@ public class DashManifestParser extends DefaultHandler @Nullable String adaptationSetLanguage, List adaptationSetRoleDescriptors, List adaptationSetAccessibilityDescriptors, + List adaptationSetEssentialProperties, List adaptationSetSupplementalProperties, @Nullable SegmentBase segmentBase, long periodDurationMs) @@ -522,7 +541,9 @@ public class DashManifestParser extends DefaultHandler String drmSchemeType = null; ArrayList drmSchemeDatas = new ArrayList<>(); ArrayList inbandEventStreams = new ArrayList<>(); - ArrayList supplementalProperties = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(adaptationSetEssentialProperties); + ArrayList supplementalProperties = + new ArrayList<>(adaptationSetSupplementalProperties); boolean seenFirstBaseUrl = false; do { @@ -555,6 +576,8 @@ public class DashManifestParser extends DefaultHandler } } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); } else { @@ -576,6 +599,7 @@ public class DashManifestParser extends DefaultHandler adaptationSetRoleDescriptors, adaptationSetAccessibilityDescriptors, codecs, + essentialProperties, supplementalProperties); segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase(); @@ -596,11 +620,14 @@ public class DashManifestParser extends DefaultHandler List roleDescriptors, List accessibilityDescriptors, @Nullable String codecs, + List essentialProperties, List supplementalProperties) { String sampleMimeType = getSampleMimeType(containerMimeType, codecs); @C.SelectionFlags int selectionFlags = parseSelectionFlagsFromRoleDescriptors(roleDescriptors); @C.RoleFlags int roleFlags = parseRoleFlagsFromRoleDescriptors(roleDescriptors); roleFlags |= parseRoleFlagsFromAccessibilityDescriptors(accessibilityDescriptors); + roleFlags |= parseRoleFlagsFromProperties(essentialProperties); + roleFlags |= parseRoleFlagsFromProperties(supplementalProperties); if (sampleMimeType != null) { if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType)) { sampleMimeType = parseEac3SupplementalProperties(supplementalProperties); @@ -1233,6 +1260,18 @@ public class DashManifestParser extends DefaultHandler return result; } + @C.RoleFlags + protected int parseRoleFlagsFromProperties(List accessibilityDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if ("http://dashif.org/guidelines/trickmode".equalsIgnoreCase(descriptor.schemeIdUri)) { + result |= C.ROLE_FLAG_TRICK_PLAY; + } + } + return result; + } + @C.RoleFlags protected int parseDashRoleSchemeValue(@Nullable String value) { if (value == null) { diff --git a/library/dash/src/test/assets/sample_mpd_trick_play b/library/dash/src/test/assets/sample_mpd_trick_play new file mode 100644 index 0000000000..b35c906b5f --- /dev/null +++ b/library/dash/src/test/assets/sample_mpd_trick_play @@ -0,0 +1,32 @@ + + + + + + + + + + + https://test.com/0 + + + + + https://test.com/0 + + + + + + https://test.com/0 + + + + + + https://test.com/0 + + + + diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java index f39a493e9f..53a9d854e2 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java @@ -165,6 +165,7 @@ public final class DashMediaPeriodTest { trackType, Arrays.asList(representations), /* accessibilityDescriptors= */ Collections.emptyList(), + /* essentialProperties= */ Collections.emptyList(), descriptor == null ? Collections.emptyList() : Collections.singletonList(descriptor)); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java index 6e769b72e1..6b8bc8ad25 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegm import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; +import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -38,21 +39,21 @@ public final class DashUtilTest { @Test public void testLoadDrmInitDataFromManifest() throws Exception { - Period period = newPeriod(newAdaptationSets(newRepresentations(newDrmInitData()))); + Period period = newPeriod(newAdaptationSet(newRepresentations(newDrmInitData()))); DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); assertThat(drmInitData).isEqualTo(newDrmInitData()); } @Test public void testLoadDrmInitDataMissing() throws Exception { - Period period = newPeriod(newAdaptationSets(newRepresentations(null /* no init data */))); + Period period = newPeriod(newAdaptationSet(newRepresentations(null /* no init data */))); DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); assertThat(drmInitData).isNull(); } @Test public void testLoadDrmInitDataNoRepresentations() throws Exception { - Period period = newPeriod(newAdaptationSets(/* no representation */ )); + Period period = newPeriod(newAdaptationSet(/* no representation */ )); DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); assertThat(drmInitData).isNull(); } @@ -68,8 +69,14 @@ public final class DashUtilTest { return new Period("", 0, Arrays.asList(adaptationSets)); } - private static AdaptationSet newAdaptationSets(Representation... representations) { - return new AdaptationSet(0, C.TRACK_TYPE_VIDEO, Arrays.asList(representations), null, null); + private static AdaptationSet newAdaptationSet(Representation... representations) { + return new AdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + Arrays.asList(representations), + /* accessibilityDescriptors= */ Collections.emptyList(), + /* essentialProperties= */ Collections.emptyList(), + /* supplementalProperties= */ Collections.emptyList()); } private static Representation newRepresentations(DrmInitData drmInitData) { diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java index 390a18d2cc..ea03770c89 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -45,6 +45,7 @@ public class DashManifestParserTest { private static final String SAMPLE_MPD_SEGMENT_TEMPLATE = "sample_mpd_segment_template"; private static final String SAMPLE_MPD_EVENT_STREAM = "sample_mpd_event_stream"; private static final String SAMPLE_MPD_LABELS = "sample_mpd_labels"; + private static final String SAMPLE_MPD_TRICK_PLAY = "sample_mpd_trick_play"; private static final String NEXT_TAG_NAME = "Next"; private static final String NEXT_TAG = "<" + NEXT_TAG_NAME + "/>"; @@ -169,7 +170,7 @@ public class DashManifestParserTest { DashManifestParser parser = new DashManifestParser(); DashManifest mpd = parser.parse( - Uri.parse("Https://example.com/test.mpd"), + Uri.parse("https://example.com/test.mpd"), TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), SAMPLE_MPD)); ProgramInformation expectedProgramInformation = new ProgramInformation( @@ -192,6 +193,46 @@ public class DashManifestParserTest { assertThat(adaptationSets.get(1).representations.get(0).format.label).isEqualTo("video label"); } + @Test + public void parseMediaPresentationDescription_trickPlay() throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), SAMPLE_MPD_TRICK_PLAY)); + + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + AdaptationSet adaptationSet = adaptationSets.get(0); + assertThat(adaptationSet.essentialProperties).isEmpty(); + assertThat(adaptationSet.supplementalProperties).isEmpty(); + assertThat(adaptationSet.representations.get(0).format.roleFlags).isEqualTo(0); + + adaptationSet = adaptationSets.get(1); + assertThat(adaptationSet.essentialProperties).isEmpty(); + assertThat(adaptationSet.supplementalProperties).isEmpty(); + assertThat(adaptationSet.representations.get(0).format.roleFlags).isEqualTo(0); + + adaptationSet = adaptationSets.get(2); + assertThat(adaptationSet.essentialProperties).hasSize(1); + assertThat(adaptationSet.essentialProperties.get(0).schemeIdUri) + .isEqualTo("http://dashif.org/guidelines/trickmode"); + assertThat(adaptationSet.essentialProperties.get(0).value).isEqualTo("0"); + assertThat(adaptationSet.supplementalProperties).isEmpty(); + assertThat(adaptationSet.representations.get(0).format.roleFlags) + .isEqualTo(C.ROLE_FLAG_TRICK_PLAY); + + adaptationSet = adaptationSets.get(3); + assertThat(adaptationSet.essentialProperties).isEmpty(); + assertThat(adaptationSet.supplementalProperties).hasSize(1); + assertThat(adaptationSet.supplementalProperties.get(0).schemeIdUri) + .isEqualTo("http://dashif.org/guidelines/trickmode"); + assertThat(adaptationSet.supplementalProperties.get(0).value).isEqualTo("1"); + assertThat(adaptationSet.representations.get(0).format.roleFlags) + .isEqualTo(C.ROLE_FLAG_TRICK_PLAY); + } + @Test public void parseSegmentTimeline_repeatCount() throws Exception { DashManifestParser parser = new DashManifestParser(); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java index a336602965..3f3b35b5b9 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java @@ -239,6 +239,12 @@ public class DashManifestTest { } private static AdaptationSet newAdaptationSet(int seed, Representation... representations) { - return new AdaptationSet(++seed, ++seed, Arrays.asList(representations), null, null); + return new AdaptationSet( + ++seed, + ++seed, + Arrays.asList(representations), + /* accessibilityDescriptors= */ Collections.emptyList(), + /* essentialProperties= */ Collections.emptyList(), + /* supplementalProperties= */ Collections.emptyList()); } } From f2d2d561096b418759454ab0fadd8c615a46d1bb Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 15 Apr 2020 18:49:16 +0100 Subject: [PATCH 0312/1052] Fix H265Reader Update H265Reader to output the same samples after a seek to 0. PiperOrigin-RevId: 306675050 --- .../exoplayer2/extractor/ts/H265Reader.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index 88bde53746..b361e4972c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -161,9 +161,8 @@ public final class H265Reader implements ElementaryStreamReader { } private void startNalUnit(long position, int offset, int nalUnitType, long pesTimeUs) { - if (hasOutputFormat) { - sampleReader.startNalUnit(position, offset, nalUnitType, pesTimeUs); - } else { + sampleReader.startNalUnit(position, offset, nalUnitType, pesTimeUs, hasOutputFormat); + if (!hasOutputFormat) { vps.startNalUnit(nalUnitType); sps.startNalUnit(nalUnitType); pps.startNalUnit(nalUnitType); @@ -173,9 +172,8 @@ public final class H265Reader implements ElementaryStreamReader { } private void nalUnitData(byte[] dataArray, int offset, int limit) { - if (hasOutputFormat) { - sampleReader.readNalUnitData(dataArray, offset, limit); - } else { + sampleReader.readNalUnitData(dataArray, offset, limit); + if (!hasOutputFormat) { vps.appendToNalUnit(dataArray, offset, limit); sps.appendToNalUnit(dataArray, offset, limit); pps.appendToNalUnit(dataArray, offset, limit); @@ -185,9 +183,8 @@ public final class H265Reader implements ElementaryStreamReader { } private void endNalUnit(long position, int offset, int discardPadding, long pesTimeUs) { - if (hasOutputFormat) { - sampleReader.endNalUnit(position, offset); - } else { + sampleReader.endNalUnit(position, offset, hasOutputFormat); + if (!hasOutputFormat) { vps.endNalUnit(discardPadding); sps.endNalUnit(discardPadding); pps.endNalUnit(discardPadding); @@ -427,7 +424,8 @@ public final class H265Reader implements ElementaryStreamReader { writingParameterSets = false; } - public void startNalUnit(long position, int offset, int nalUnitType, long pesTimeUs) { + public void startNalUnit( + long position, int offset, int nalUnitType, long pesTimeUs, boolean hasOutputFormat) { isFirstSlice = false; isFirstParameterSet = false; nalUnitTimeUs = pesTimeUs; @@ -437,7 +435,9 @@ public final class H265Reader implements ElementaryStreamReader { if (nalUnitType >= VPS_NUT) { if (!writingParameterSets && readingSample) { // This is a non-VCL NAL unit, so flush the previous sample. - outputSample(offset); + if (hasOutputFormat) { + outputSample(offset); + } readingSample = false; } if (nalUnitType <= PPS_NUT) { @@ -464,14 +464,14 @@ public final class H265Reader implements ElementaryStreamReader { } } - public void endNalUnit(long position, int offset) { + public void endNalUnit(long position, int offset, boolean hasOutputFormat) { if (writingParameterSets && isFirstSlice) { // This sample has parameter sets. Reset the key-frame flag based on the first slice. sampleIsKeyframe = nalUnitHasKeyframeData; writingParameterSets = false; } else if (isFirstParameterSet || isFirstSlice) { // This NAL unit is at the start of a new sample (access unit). - if (readingSample) { + if (hasOutputFormat && readingSample) { // Output the sample ending before this NAL unit. int nalUnitLength = (int) (position - nalUnitStartPosition); outputSample(offset + nalUnitLength); From 4dc1d317c3381ce6d5103f5bfca6940527cc1c5c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 16 Apr 2020 10:29:57 +0100 Subject: [PATCH 0313/1052] Fix TeeAudioProcessor sink configuration TeeAudioProcessor needs to configure its sink when it is initially set up. PiperOrigin-RevId: 306808871 --- .../exoplayer2/audio/TeeAudioProcessor.java | 7 ++- .../audio/TeeAudioProcessorTest.java | 58 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java index b6a063bd14..a9afa47198 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java @@ -79,6 +79,11 @@ public final class TeeAudioProcessor extends BaseAudioProcessor { replaceOutputBuffer(remaining).put(inputBuffer).flip(); } + @Override + protected void onFlush() { + flushSinkIfActive(); + } + @Override protected void onQueueEndOfStream() { flushSinkIfActive(); @@ -201,7 +206,7 @@ public final class TeeAudioProcessor extends BaseAudioProcessor { } private void reset() throws IOException { - RandomAccessFile randomAccessFile = this.randomAccessFile; + @Nullable RandomAccessFile randomAccessFile = this.randomAccessFile; if (randomAccessFile == null) { return; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java new file mode 100644 index 0000000000..6f0a87e97b --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static org.mockito.Mockito.verify; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.AudioProcessor.AudioFormat; +import com.google.android.exoplayer2.audio.TeeAudioProcessor.AudioBufferSink; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link TeeAudioProcessorTest}. */ +@RunWith(AndroidJUnit4.class) +public final class TeeAudioProcessorTest { + + private static final AudioFormat AUDIO_FORMAT = + new AudioFormat(/* sampleRate= */ 44100, /* channelCount= */ 2, C.ENCODING_PCM_16BIT); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private TeeAudioProcessor teeAudioProcessor; + + @Mock private AudioBufferSink mockAudioBufferSink; + + @Before + public void setUp() { + teeAudioProcessor = new TeeAudioProcessor(mockAudioBufferSink); + } + + @Test + public void initialFlush_flushesSink() throws Exception { + teeAudioProcessor.configure(AUDIO_FORMAT); + teeAudioProcessor.flush(); + + verify(mockAudioBufferSink) + .flush(AUDIO_FORMAT.sampleRate, AUDIO_FORMAT.channelCount, AUDIO_FORMAT.encoding); + } +} From aea9f8e550f701478c5f0121fc6f51bb409de557 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 17 Apr 2020 10:44:42 +0100 Subject: [PATCH 0314/1052] Merge pull request #7245 from Clement-Jean:silence-media-source-factory PiperOrigin-RevId: 307010600 --- RELEASENOTES.md | 2 + .../exoplayer2/source/SilenceMediaSource.java | 49 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3ae572c5b2..3b5be74ebb 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,8 @@ ### Next release ### +* Add `SilenceMediaSource.Factory` to support tags + ([PR #7245](https://github.com/google/ExoPlayer/pull/7245)). * Avoid throwing an exception while parsing fragmented MP4 default sample values where the most-significant bit is set ([#7207](https://github.com/google/ExoPlayer/issues/7207)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index abaf33633e..773eba732b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -33,6 +33,42 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Media source with a single period consisting of silent raw audio of a given duration. */ public final class SilenceMediaSource extends BaseMediaSource { + /** Factory for {@link SilenceMediaSource SilenceMediaSources}. */ + public static final class Factory { + + private long durationUs; + @Nullable private Object tag; + + /** + * Sets the duration of the silent audio. + * + * @param durationUs The duration of silent audio to output, in microseconds. + * @return This factory, for convenience. + */ + public Factory setDurationUs(long durationUs) { + this.durationUs = durationUs; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + */ + public Factory setTag(@Nullable Object tag) { + this.tag = tag; + return this; + } + + /** Creates a new {@link SilenceMediaSource}. */ + public SilenceMediaSource createMediaSource() { + return new SilenceMediaSource(durationUs, tag); + } + } + private static final int SAMPLE_RATE_HZ = 44100; @C.PcmEncoding private static final int ENCODING = C.ENCODING_PCM_16BIT; private static final int CHANNEL_COUNT = 2; @@ -54,6 +90,7 @@ public final class SilenceMediaSource extends BaseMediaSource { new byte[Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT) * 1024]; private final long durationUs; + @Nullable private final Object tag; /** * Creates a new media source providing silent audio of the given duration. @@ -61,15 +98,25 @@ public final class SilenceMediaSource extends BaseMediaSource { * @param durationUs The duration of silent audio to output, in microseconds. */ public SilenceMediaSource(long durationUs) { + this(durationUs, /* tag= */ null); + } + + private SilenceMediaSource(long durationUs, @Nullable Object tag) { Assertions.checkArgument(durationUs >= 0); this.durationUs = durationUs; + this.tag = tag; } @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { refreshSourceInfo( new SinglePeriodTimeline( - durationUs, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false)); + durationUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + tag)); } @Override From ad36f649654db90bded9f8d150a128f69653b5b4 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Mon, 20 Apr 2020 13:28:27 +0100 Subject: [PATCH 0315/1052] Merge pull request #7210 from nebyan:CacheKeyFactoryNotUsed PiperOrigin-RevId: 307045655 --- .../exoplayer2/upstream/cache/CacheUtil.java | 18 ++++++++---------- .../upstream/cache/CacheDataSourceTest.java | 4 ---- .../upstream/cache/CacheUtilTest.java | 16 ++++------------ 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index ce16ea2439..9f1fc54462 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -109,7 +109,6 @@ public final class CacheUtil { * * @param dataSpec Defines the data to be cached. * @param cache A {@link Cache} to store the data. - * @param cacheKeyFactory An optional factory for cache keys. * @param upstream A {@link DataSource} for reading data not in the cache. * @param progressListener A listener to receive progress updates, or {@code null}. * @param isCanceled An optional flag that will interrupt caching if set to true. @@ -120,7 +119,6 @@ public final class CacheUtil { public static void cache( DataSpec dataSpec, Cache cache, - @Nullable CacheKeyFactory cacheKeyFactory, DataSource upstream, @Nullable ProgressListener progressListener, @Nullable AtomicBoolean isCanceled) @@ -128,7 +126,7 @@ public final class CacheUtil { cache( dataSpec, cache, - cacheKeyFactory, + /* cacheKeyFactory= */ null, new CacheDataSource(cache, upstream), new byte[DEFAULT_BUFFER_SIZE_BYTES], /* priorityTaskManager= */ null, @@ -139,14 +137,14 @@ public final class CacheUtil { } /** - * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops - * early if end of input is reached and {@code enableEOFException} is false. + * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early + * if end of input is reached and {@code enableEOFException} is false. * - *

    If a {@link PriorityTaskManager} is given, it's used to pause and resume caching depending - * on {@code priority} and the priority of other tasks registered to the PriorityTaskManager. - * Please note that it's the responsibility of the calling code to call {@link - * PriorityTaskManager#add} to register with the manager before calling this method, and to call - * {@link PriorityTaskManager#remove} afterwards to unregister. + *

    If a {@link PriorityTaskManager} is provided, it's used to pause and resume caching + * depending on {@code priority} and the priority of other tasks registered to the + * PriorityTaskManager. Please note that it's the responsibility of the calling code to call + * {@link PriorityTaskManager#add} to register with the manager before calling this method, and to + * call {@link PriorityTaskManager#remove} afterwards to unregister. * *

    This method may be slow and shouldn't normally be called on the main thread. * diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 27438fcac3..8862a65db2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -365,7 +365,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( unboundedDataSpec, cache, - /* cacheKeyFactory= */ null, upstream2, /* progressListener= */ null, /* isCanceled= */ null); @@ -414,7 +413,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( unboundedDataSpec, cache, - /* cacheKeyFactory= */ null, upstream2, /* progressListener= */ null, /* isCanceled= */ null); @@ -438,7 +436,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( dataSpec, cache, - /* cacheKeyFactory= */ null, upstream, /* progressListener= */ null, /* isCanceled= */ null); @@ -474,7 +471,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( dataSpec, cache, - /* cacheKeyFactory= */ null, upstream, /* progressListener= */ null, /* isCanceled= */ null); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java index 9a449b2ebd..69463bff54 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -207,7 +207,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(Uri.parse("test_data")), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); @@ -224,8 +223,7 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, 10, 20, null); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 20, 20); counters.reset(); @@ -233,7 +231,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(testUri), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); @@ -251,8 +248,7 @@ public final class CacheUtilTest { DataSpec dataSpec = new DataSpec(Uri.parse("test_data")); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); @@ -268,8 +264,7 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, 10, 20, null); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 20, 20); counters.reset(); @@ -277,7 +272,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(testUri), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); @@ -294,8 +288,7 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, 0, 1000, null); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 100, 1000); assertCachedData(cache, fakeDataSet); @@ -344,7 +337,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(Uri.parse("test_data")), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); From e6b5e6eb6ee5797c6a5b9b962c1516eb388a375e Mon Sep 17 00:00:00 2001 From: olly Date: Sun, 19 Apr 2020 17:12:26 +0100 Subject: [PATCH 0316/1052] Merge trick play tracks into main track groups Issue: #6054 PiperOrigin-RevId: 307285068 --- RELEASENOTES.md | 5 + .../source/dash/DashMediaPeriod.java | 121 +++++++--- .../source/dash/DashMediaPeriodTest.java | 222 +++++++++++++++--- .../testutil/MediaPeriodAsserts.java | 19 +- 4 files changed, 292 insertions(+), 75 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3b5be74ebb..625ed1fe77 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -7,6 +7,11 @@ * Avoid throwing an exception while parsing fragmented MP4 default sample values where the most-significant bit is set ([#7207](https://github.com/google/ExoPlayer/issues/7207)). +* DASH: + * Merge trick play adaptation sets (i.e., adaptation sets marked with + `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as + the main adaptation sets to which they refer. Trick play tracks are + marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. ### 2.11.4 (2020-04-08) ### diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 88de84603e..fa8e5338fc 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source.dash; import android.util.Pair; +import android.util.SparseArray; import android.util.SparseIntArray; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -516,50 +517,94 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return Pair.create(new TrackGroupArray(trackGroups), trackGroupInfos); } + /** + * Groups adaptation sets. Two adaptations sets belong to the same group if either: + * + *

      + *
    • One is a trick-play adaptation set and uses a {@code + * http://dashif.org/guidelines/trickmode} essential or supplemental property to indicate + * that the other is the main adaptation set to which it corresponds. + *
    • The two adaptation sets are marked as safe for switching using {@code + * urn:mpeg:dash:adaptation-set-switching:2016} supplemental properties. + *
    + * + * @param adaptationSets The adaptation sets to merge. + * @return An array of groups, where each group is an array of adaptation set indices. + */ private static int[][] getGroupedAdaptationSetIndices(List adaptationSets) { int adaptationSetCount = adaptationSets.size(); - SparseIntArray idToIndexMap = new SparseIntArray(adaptationSetCount); + SparseIntArray adaptationSetIdToIndex = new SparseIntArray(adaptationSetCount); + List> adaptationSetGroupedIndices = new ArrayList<>(adaptationSetCount); + SparseArray> adaptationSetIndexToGroupedIndices = + new SparseArray<>(adaptationSetCount); + + // Initially make each adaptation set belong to its own group. Also build the + // adaptationSetIdToIndex map. for (int i = 0; i < adaptationSetCount; i++) { - idToIndexMap.put(adaptationSets.get(i).id, i); + adaptationSetIdToIndex.put(adaptationSets.get(i).id, i); + List initialGroup = new ArrayList<>(); + initialGroup.add(i); + adaptationSetGroupedIndices.add(initialGroup); + adaptationSetIndexToGroupedIndices.put(i, initialGroup); } - int[][] groupedAdaptationSetIndices = new int[adaptationSetCount][]; - boolean[] adaptationSetUsedFlags = new boolean[adaptationSetCount]; - - int groupCount = 0; + // Merge adaptation set groups. for (int i = 0; i < adaptationSetCount; i++) { - if (adaptationSetUsedFlags[i]) { - // This adaptation set has already been included in a group. - continue; + int mergedGroupIndex = i; + AdaptationSet adaptationSet = adaptationSets.get(i); + + // Trick-play adaptation sets are merged with their corresponding main adaptation sets. + @Nullable + Descriptor trickPlayProperty = findTrickPlayProperty(adaptationSet.essentialProperties); + if (trickPlayProperty == null) { + // Trick-play can also be specified using a supplemental property. + trickPlayProperty = findTrickPlayProperty(adaptationSet.supplementalProperties); } - adaptationSetUsedFlags[i] = true; - Descriptor adaptationSetSwitchingProperty = findAdaptationSetSwitchingProperty( - adaptationSets.get(i).supplementalProperties); - if (adaptationSetSwitchingProperty == null) { - groupedAdaptationSetIndices[groupCount++] = new int[] {i}; - } else { - String[] extraAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); - int[] adaptationSetIndices = new int[1 + extraAdaptationSetIds.length]; - adaptationSetIndices[0] = i; - int outputIndex = 1; - for (String adaptationSetId : extraAdaptationSetIds) { - int extraIndex = - idToIndexMap.get(Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); - if (extraIndex != -1) { - adaptationSetUsedFlags[extraIndex] = true; - adaptationSetIndices[outputIndex] = extraIndex; - outputIndex++; + if (trickPlayProperty != null) { + int mainAdaptationSetId = Integer.parseInt(trickPlayProperty.value); + int mainAdaptationSetIndex = + adaptationSetIdToIndex.get(mainAdaptationSetId, /* valueIfKeyNotFound= */ -1); + if (mainAdaptationSetIndex != -1) { + mergedGroupIndex = mainAdaptationSetIndex; + } + } + + // Adaptation sets that are safe for switching are merged, using the smallest index for the + // merged group. + if (mergedGroupIndex == i) { + @Nullable + Descriptor adaptationSetSwitchingProperty = + findAdaptationSetSwitchingProperty(adaptationSet.supplementalProperties); + if (adaptationSetSwitchingProperty != null) { + String[] otherAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); + for (String adaptationSetId : otherAdaptationSetIds) { + int otherAdaptationSetId = + adaptationSetIdToIndex.get( + Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); + if (otherAdaptationSetId != -1) { + mergedGroupIndex = Math.min(mergedGroupIndex, otherAdaptationSetId); + } } } - if (outputIndex < adaptationSetIndices.length) { - adaptationSetIndices = Arrays.copyOf(adaptationSetIndices, outputIndex); - } - groupedAdaptationSetIndices[groupCount++] = adaptationSetIndices; + } + + // Merge the groups if necessary. + if (mergedGroupIndex != i) { + List thisGroup = adaptationSetIndexToGroupedIndices.get(i); + List mergedGroup = adaptationSetIndexToGroupedIndices.get(mergedGroupIndex); + mergedGroup.addAll(thisGroup); + adaptationSetIndexToGroupedIndices.put(i, mergedGroup); + adaptationSetGroupedIndices.remove(thisGroup); } } - return groupCount < adaptationSetCount - ? Arrays.copyOf(groupedAdaptationSetIndices, groupCount) : groupedAdaptationSetIndices; + int[][] groupedAdaptationSetIndices = new int[adaptationSetGroupedIndices.size()][]; + for (int i = 0; i < groupedAdaptationSetIndices.length; i++) { + groupedAdaptationSetIndices[i] = Util.toArray(adaptationSetGroupedIndices.get(i)); + // Restore the original adaptation set order within each group. + Arrays.sort(groupedAdaptationSetIndices[i]); + } + return groupedAdaptationSetIndices; } /** @@ -739,9 +784,19 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } private static Descriptor findAdaptationSetSwitchingProperty(List descriptors) { + return findDescriptor(descriptors, "urn:mpeg:dash:adaptation-set-switching:2016"); + } + + @Nullable + private static Descriptor findTrickPlayProperty(List descriptors) { + return findDescriptor(descriptors, "http://dashif.org/guidelines/trickmode"); + } + + @Nullable + private static Descriptor findDescriptor(List descriptors, String schemeIdUri) { for (int i = 0; i < descriptors.size(); i++) { Descriptor descriptor = descriptors.get(i); - if ("urn:mpeg:dash:adaptation-set-switching:2016".equals(descriptor.schemeIdUri)) { + if (schemeIdUri.equals(descriptor.schemeIdUri)) { return descriptor; } } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java index 53a9d854e2..9e74ddde45 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java @@ -26,6 +26,8 @@ import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.dash.PlayerEmsgHandler.PlayerEmsgCallback; import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; @@ -35,7 +37,6 @@ import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; -import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoaderErrorThrower; @@ -43,6 +44,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.LooperMode; @@ -53,7 +55,7 @@ import org.robolectric.annotation.LooperMode; public final class DashMediaPeriodTest { @Test - public void getSteamKeys_isCompatibleWithDashManifestFilter() { + public void getStreamKeys_isCompatibleWithDashManifestFilter() { // Test manifest which covers various edge cases: // - Multiple periods. // - Single and multiple representations per adaptation set. @@ -61,83 +63,220 @@ public final class DashMediaPeriodTest { // - Embedded track groups. // All cases are deliberately combined in one test to catch potential indexing problems which // only occur in combination. - DashManifest testManifest = + DashManifest manifest = createDashManifest( createPeriod( createAdaptationSet( /* id= */ 0, - /* trackType= */ C.TRACK_TYPE_VIDEO, + C.TRACK_TYPE_VIDEO, /* descriptor= */ null, createVideoRepresentation(/* bitrate= */ 1000000))), createPeriod( createAdaptationSet( /* id= */ 100, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 103, 104), + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 103, 104), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 200000), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 400000), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 600000)), createAdaptationSet( /* id= */ 101, - /* trackType= */ C.TRACK_TYPE_AUDIO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 102), + C.TRACK_TYPE_AUDIO, + createSwitchDescriptor(/* ids...= */ 102), createAudioRepresentation(/* bitrate= */ 48000), createAudioRepresentation(/* bitrate= */ 96000)), createAdaptationSet( /* id= */ 102, - /* trackType= */ C.TRACK_TYPE_AUDIO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 101), + C.TRACK_TYPE_AUDIO, + createSwitchDescriptor(/* ids...= */ 101), createAudioRepresentation(/* bitrate= */ 256000)), createAdaptationSet( /* id= */ 103, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 100, 104), + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 100, 104), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 800000), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 1000000)), createAdaptationSet( /* id= */ 104, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 100, 103), + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 100, 103), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 2000000)), createAdaptationSet( /* id= */ 105, - /* trackType= */ C.TRACK_TYPE_TEXT, + C.TRACK_TYPE_TEXT, /* descriptor= */ null, createTextRepresentation(/* language= */ "eng")), createAdaptationSet( /* id= */ 105, - /* trackType= */ C.TRACK_TYPE_TEXT, + C.TRACK_TYPE_TEXT, /* descriptor= */ null, createTextRepresentation(/* language= */ "ger")))); - FilterableManifestMediaPeriodFactory mediaPeriodFactory = - (manifest, periodIndex) -> - new DashMediaPeriod( - /* id= */ periodIndex, - manifest, - periodIndex, - mock(DashChunkSource.Factory.class), - mock(TransferListener.class), - DrmSessionManager.getDummyDrmSessionManager(), - mock(LoadErrorHandlingPolicy.class), - new EventDispatcher() - .withParameters( - /* windowIndex= */ 0, - /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), - /* mediaTimeOffsetMs= */ 0), - /* elapsedRealtimeOffsetMs= */ 0, - mock(LoaderErrorThrower.class), - mock(Allocator.class), - mock(CompositeSequenceableLoaderFactory.class), - mock(PlayerEmsgCallback.class)); // Ignore embedded metadata as we don't want to select primary group just to get embedded track. MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( - mediaPeriodFactory, - testManifest, + DashMediaPeriodTest::createDashMediaPeriod, + manifest, /* periodIndex= */ 1, /* ignoredMimeType= */ "application/x-emsg"); } + @Test + public void adaptationSetSwitchingProperty_mergesTrackGroups() { + DashManifest manifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 1, 2), + createVideoRepresentation(/* bitrate= */ 0), + createVideoRepresentation(/* bitrate= */ 1)), + createAdaptationSet( + /* id= */ 3, + C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 300)), + createAdaptationSet( + /* id= */ 2, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 0, 1), + createVideoRepresentation(/* bitrate= */ 200), + createVideoRepresentation(/* bitrate= */ 201)), + createAdaptationSet( + /* id= */ 1, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 0, 2), + createVideoRepresentation(/* bitrate= */ 100)))); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect the three adaptation sets with the switch descriptor to be merged, retaining the + // representations in their original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format), + new TrackGroup(adaptationSets.get(1).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + @Test + public void trickPlayProperty_mergesTrackGroups() { + DashManifest manifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 1), + createVideoRepresentation(/* bitrate= */ 0), + createVideoRepresentation(/* bitrate= */ 1)), + createAdaptationSet( + /* id= */ 1, + C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 100)), + createAdaptationSet( + /* id= */ 2, + C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 200), + createVideoRepresentation(/* bitrate= */ 201)), + createAdaptationSet( + /* id= */ 3, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 2), + createVideoRepresentation(/* bitrate= */ 300)))); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect the trick play adaptation sets to be merged with the ones to which they refer, + // retaining representations in their original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(1).representations.get(0).format), + new TrackGroup( + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + @Test + public void adaptationSetSwitchingProperty_andTrickPlayProperty_mergesTrackGroups() { + DashManifest manifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 1), + createVideoRepresentation(/* bitrate= */ 0), + createVideoRepresentation(/* bitrate= */ 1)), + createAdaptationSet( + /* id= */ 1, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 2), + createVideoRepresentation(/* bitrate= */ 100)), + createAdaptationSet( + /* id= */ 2, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 1), + createVideoRepresentation(/* bitrate= */ 200), + createVideoRepresentation(/* bitrate= */ 201)), + createAdaptationSet( + /* id= */ 3, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 2), + createVideoRepresentation(/* bitrate= */ 300)))); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect all adaptation sets to be merged into one group, retaining representations in their + // original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(1).representations.get(0).format, + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + private static DashMediaPeriod createDashMediaPeriod(DashManifest manifest, int periodIndex) { + return new DashMediaPeriod( + /* id= */ periodIndex, + manifest, + periodIndex, + mock(DashChunkSource.Factory.class), + mock(TransferListener.class), + DrmSessionManager.getDummyDrmSessionManager(), + mock(LoadErrorHandlingPolicy.class), + new EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), + /* mediaTimeOffsetMs= */ 0), + /* elapsedRealtimeOffsetMs= */ 0, + mock(LoaderErrorThrower.class), + mock(Allocator.class), + mock(CompositeSequenceableLoaderFactory.class), + mock(PlayerEmsgCallback.class)); + } + private static DashManifest createDashManifest(Period... periods) { return new DashManifest( /* availabilityStartTimeMs= */ 0, @@ -245,6 +384,13 @@ public final class DashMediaPeriodTest { /* id= */ null); } + private static Descriptor createTrickPlayDescriptor(int mainAdaptationSetId) { + return new Descriptor( + /* schemeIdUri= */ "http://dashif.org/guidelines/trickmode", + /* value= */ Integer.toString(mainAdaptationSetId), + /* id= */ null); + } + private static Descriptor getInbandEventDescriptor() { return new Descriptor( /* schemeIdUri= */ "inBandSchemeIdUri", /* value= */ "inBandValue", /* id= */ "inBandId"); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java index 42fc40e72d..82f56b262e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java @@ -54,6 +54,17 @@ public final class MediaPeriodAsserts { private MediaPeriodAsserts() {} + /** + * Prepares the {@link MediaPeriod} and asserts that it provides the specified track groups. + * + * @param mediaPeriod The {@link MediaPeriod} to test. + * @param expectedGroups The expected track groups. + */ + public static void assertTrackGroups(MediaPeriod mediaPeriod, TrackGroupArray expectedGroups) { + TrackGroupArray actualGroups = prepareAndGetTrackGroups(mediaPeriod); + assertThat(actualGroups).isEqualTo(expectedGroups); + } + /** * Asserts that the values returns by {@link MediaPeriod#getStreamKeys(List)} are compatible with * a {@link FilterableManifest} using these stream keys. @@ -85,7 +96,7 @@ public final class MediaPeriodAsserts { int periodIndex, @Nullable String ignoredMimeType) { MediaPeriod mediaPeriod = mediaPeriodFactory.createMediaPeriod(manifest, periodIndex); - TrackGroupArray trackGroupArray = getTrackGroups(mediaPeriod); + TrackGroupArray trackGroupArray = prepareAndGetTrackGroups(mediaPeriod); // Create test vector of query test selections: // - One selection with one track per group, two tracks or all tracks. @@ -146,7 +157,7 @@ public final class MediaPeriodAsserts { // The filtered manifest should only have one period left. MediaPeriod filteredMediaPeriod = mediaPeriodFactory.createMediaPeriod(filteredManifest, /* periodIndex= */ 0); - TrackGroupArray filteredTrackGroupArray = getTrackGroups(filteredMediaPeriod); + TrackGroupArray filteredTrackGroupArray = prepareAndGetTrackGroups(filteredMediaPeriod); for (TrackSelection trackSelection : testSelection) { if (ignoredMimeType != null && ignoredMimeType.equals(trackSelection.getFormat(0).sampleMimeType)) { @@ -186,8 +197,8 @@ public final class MediaPeriodAsserts { return true; } - private static TrackGroupArray getTrackGroups(MediaPeriod mediaPeriod) { - AtomicReference trackGroupArray = new AtomicReference<>(null); + private static TrackGroupArray prepareAndGetTrackGroups(MediaPeriod mediaPeriod) { + AtomicReference trackGroupArray = new AtomicReference<>(); DummyMainThread dummyMainThread = new DummyMainThread(); ConditionVariable preparedCondition = new ConditionVariable(); dummyMainThread.runOnMainThread( From cf52742ad9ce27e721240177ad139feff6683826 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 20 Apr 2020 08:15:18 +0100 Subject: [PATCH 0317/1052] Fix gapless playback Audio processors are now flushed twice after reconfiguration. The second flush call cleared the pending trim start bytes so transitions between tracks were no longer gapless. Fix this by removing logic to clear pending trim bytes on flush. As a result we may trim data incorrectly if there is a flush before any data has been handled for seeking to a non-zero position, but this edge case will happen rarely and the effect shouldn't be noticeable. PiperOrigin-RevId: 307344357 --- .../audio/TrimmingAudioProcessor.java | 18 ++-- .../audio/TrimmingAudioProcessorTest.java | 101 ++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java index 8d84325d93..f630c267e6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java @@ -155,18 +155,20 @@ import java.nio.ByteBuffer; @Override protected void onFlush() { if (reconfigurationPending) { - // This is the initial flush after reconfiguration. Prepare to trim bytes from the start/end. + // Flushing activates the new configuration, so prepare to trim bytes from the start/end. reconfigurationPending = false; endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame]; pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame; - } else { - // This is a flush during playback (after the initial flush). We assume this was caused by a - // seek to a non-zero position and clear pending start bytes. This assumption may be wrong (we - // may be seeking to zero), but playing data that should have been trimmed shouldn't be - // noticeable after a seek. Ideally we would check the timestamp of the first input buffer - // queued after flushing to decide whether to trim (see also [Internal: b/77292509]). - pendingTrimStartBytes = 0; } + + // TODO(internal b/77292509): Flushing occurs to activate a configuration (handled above) but + // also when seeking within a stream. This implementation currently doesn't handle seek to start + // (where we need to trim at the start again), nor seeks to non-zero positions before start + // trimming has occurred (where we should set pendingTrimStartBytes to zero). These cases can be + // fixed by trimming in queueInput based on timestamp, once that information is available. + + // Any data in the end buffer should no longer be output if we are playing from a different + // position, so discard it and refill the buffer using new input. endBufferSize = 0; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java new file mode 100644 index 0000000000..19a1ad19c3 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.AudioProcessor.AudioFormat; +import java.nio.ByteBuffer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link TrimmingAudioProcessor}. */ +@RunWith(AndroidJUnit4.class) +public final class TrimmingAudioProcessorTest { + + private static final AudioFormat AUDIO_FORMAT = + new AudioFormat(/* sampleRate= */ 44100, /* channelCount= */ 2, C.ENCODING_PCM_16BIT); + private static final int TRACK_ONE_UNTRIMMED_FRAME_COUNT = 1024; + private static final int TRACK_ONE_TRIM_START_FRAME_COUNT = 64; + private static final int TRACK_ONE_TRIM_END_FRAME_COUNT = 32; + private static final int TRACK_TWO_TRIM_START_FRAME_COUNT = 128; + private static final int TRACK_TWO_TRIM_END_FRAME_COUNT = 16; + + private static final int TRACK_ONE_BUFFER_SIZE_BYTES = + AUDIO_FORMAT.bytesPerFrame * TRACK_ONE_UNTRIMMED_FRAME_COUNT; + private static final int TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES = + TRACK_ONE_BUFFER_SIZE_BYTES + - AUDIO_FORMAT.bytesPerFrame + * (TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT); + + private TrimmingAudioProcessor trimmingAudioProcessor; + + @Before + public void setUp() { + trimmingAudioProcessor = new TrimmingAudioProcessor(); + } + + @After + public void tearDown() { + trimmingAudioProcessor.reset(); + } + + @Test + public void flushTwice_trimsStartAndEnd() throws Exception { + trimmingAudioProcessor.setTrimFrameCount( + TRACK_ONE_TRIM_START_FRAME_COUNT, TRACK_ONE_TRIM_END_FRAME_COUNT); + trimmingAudioProcessor.configure(AUDIO_FORMAT); + trimmingAudioProcessor.flush(); + trimmingAudioProcessor.flush(); + + int outputSizeBytes = feedAndDrainAudioProcessorToEndOfTrackOne(); + + assertThat(trimmingAudioProcessor.getTrimmedFrameCount()) + .isEqualTo(TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT); + assertThat(outputSizeBytes).isEqualTo(TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES); + } + + /** + * Feeds and drains the audio processor up to the end of track one, returning the total output + * size in bytes. + */ + private int feedAndDrainAudioProcessorToEndOfTrackOne() throws Exception { + // Feed and drain the processor, simulating a gapless transition to another track. + ByteBuffer inputBuffer = ByteBuffer.allocate(TRACK_ONE_BUFFER_SIZE_BYTES); + int outputSize = 0; + while (!trimmingAudioProcessor.isEnded()) { + if (inputBuffer.hasRemaining()) { + trimmingAudioProcessor.queueInput(inputBuffer); + if (!inputBuffer.hasRemaining()) { + // Reconfigure for a next track then begin draining. + trimmingAudioProcessor.setTrimFrameCount( + TRACK_TWO_TRIM_START_FRAME_COUNT, TRACK_TWO_TRIM_END_FRAME_COUNT); + trimmingAudioProcessor.configure(AUDIO_FORMAT); + trimmingAudioProcessor.queueEndOfStream(); + } + } + ByteBuffer outputBuffer = trimmingAudioProcessor.getOutput(); + outputSize += outputBuffer.remaining(); + outputBuffer.clear(); + } + trimmingAudioProcessor.reset(); + return outputSize; + } +} From e7e74afbff7eb35ef31d37c23c41c86ad4e31467 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 20 Apr 2020 11:15:53 +0100 Subject: [PATCH 0318/1052] Fix AdsMediaSource child sources not being released Also add unit tests for AdsMediaSource. PiperOrigin-RevId: 307365492 --- RELEASENOTES.md | 1 + .../google/android/exoplayer2/Timeline.java | 117 ++++++++++ .../exoplayer2/source/ads/AdsMediaSource.java | 186 ++++++++------- .../source/ads/AdsMediaSourceTest.java | 216 ++++++++++++++++++ 4 files changed, 435 insertions(+), 85 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 625ed1fe77..37f7d884f8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -7,6 +7,7 @@ * Avoid throwing an exception while parsing fragmented MP4 default sample values where the most-significant bit is set ([#7207](https://github.com/google/ExoPlayer/issues/7207)). +* Fix `AdsMediaSource` child `MediaSource`s not being released. * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index 7423320d8b..93a87da0dc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -19,6 +19,7 @@ import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; /** * A flexible representation of the structure of media. A timeline is able to represent the @@ -278,6 +279,48 @@ public abstract class Timeline { return positionInFirstPeriodUs; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + Window that = (Window) obj; + return Util.areEqual(uid, that.uid) + && Util.areEqual(tag, that.tag) + && Util.areEqual(manifest, that.manifest) + && presentationStartTimeMs == that.presentationStartTimeMs + && windowStartTimeMs == that.windowStartTimeMs + && isSeekable == that.isSeekable + && isDynamic == that.isDynamic + && isLive == that.isLive + && defaultPositionUs == that.defaultPositionUs + && durationUs == that.durationUs + && firstPeriodIndex == that.firstPeriodIndex + && lastPeriodIndex == that.lastPeriodIndex + && positionInFirstPeriodUs == that.positionInFirstPeriodUs; + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + uid.hashCode(); + result = 31 * result + (tag == null ? 0 : tag.hashCode()); + result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); + result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); + result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); + result = 31 * result + (isSeekable ? 1 : 0); + result = 31 * result + (isDynamic ? 1 : 0); + result = 31 * result + (isLive ? 1 : 0); + result = 31 * result + (int) (defaultPositionUs ^ (defaultPositionUs >>> 32)); + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + firstPeriodIndex; + result = 31 * result + lastPeriodIndex; + result = 31 * result + (int) (positionInFirstPeriodUs ^ (positionInFirstPeriodUs >>> 32)); + return result; + } } /** @@ -534,6 +577,34 @@ public abstract class Timeline { return adPlaybackState.adResumePositionUs; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + Period that = (Period) obj; + return Util.areEqual(id, that.id) + && Util.areEqual(uid, that.uid) + && windowIndex == that.windowIndex + && durationUs == that.durationUs + && positionInWindowUs == that.positionInWindowUs + && Util.areEqual(adPlaybackState, that.adPlaybackState); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + (id == null ? 0 : id.hashCode()); + result = 31 * result + (uid == null ? 0 : uid.hashCode()); + result = 31 * result + windowIndex; + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); + result = 31 * result + (adPlaybackState == null ? 0 : adPlaybackState.hashCode()); + return result; + } } /** An empty timeline. */ @@ -834,4 +905,50 @@ public abstract class Timeline { * @return The unique id of the period. */ public abstract Object getUidOfPeriod(int periodIndex); + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Timeline)) { + return false; + } + Timeline other = (Timeline) obj; + if (other.getWindowCount() != getWindowCount() || other.getPeriodCount() != getPeriodCount()) { + return false; + } + Timeline.Window window = new Timeline.Window(); + Timeline.Period period = new Timeline.Period(); + Timeline.Window otherWindow = new Timeline.Window(); + Timeline.Period otherPeriod = new Timeline.Period(); + for (int i = 0; i < getWindowCount(); i++) { + if (!getWindow(i, window).equals(other.getWindow(i, otherWindow))) { + return false; + } + } + for (int i = 0; i < getPeriodCount(); i++) { + if (!getPeriod(i, period, /* setIds= */ true) + .equals(other.getPeriod(i, otherPeriod, /* setIds= */ true))) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + Window window = new Window(); + Period period = new Period(); + int result = 7; + result = 31 * result + getWindowCount(); + for (int i = 0; i < getWindowCount(); i++) { + result = 31 * result + getWindow(i, window).hashCode(); + } + result = 31 * result + getPeriodCount(); + for (int i = 0; i < getPeriodCount(); i++) { + result = 31 * result + getPeriod(i, period, /* setIds= */ true).hashCode(); + } + return result; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 5e22de4320..34f0d496a2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -44,10 +44,9 @@ import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A {@link MediaSource} that inserts ads linearly with a provided content media source. This source @@ -128,15 +127,13 @@ public final class AdsMediaSource extends CompositeMediaSource { private final AdsLoader adsLoader; private final AdsLoader.AdViewProvider adViewProvider; private final Handler mainHandler; - private final Map> maskingMediaPeriodByAdMediaSource; private final Timeline.Period period; // Accessed on the player thread. @Nullable private ComponentListener componentListener; @Nullable private Timeline contentTimeline; @Nullable private AdPlaybackState adPlaybackState; - private @NullableType MediaSource[][] adGroupMediaSources; - private @NullableType Timeline[][] adGroupTimelines; + private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders; /** * Constructs a new source that inserts ads linearly with the content specified by {@code @@ -178,10 +175,8 @@ public final class AdsMediaSource extends CompositeMediaSource { this.adsLoader = adsLoader; this.adViewProvider = adViewProvider; mainHandler = new Handler(Looper.getMainLooper()); - maskingMediaPeriodByAdMediaSource = new HashMap<>(); period = new Timeline.Period(); - adGroupMediaSources = new MediaSource[0][]; - adGroupTimelines = new Timeline[0][]; + adMediaSourceHolders = new AdMediaSourceHolder[0][]; adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); } @@ -208,36 +203,21 @@ public final class AdsMediaSource extends CompositeMediaSource { int adIndexInAdGroup = id.adIndexInAdGroup; Uri adUri = Assertions.checkNotNull(adPlaybackState.adGroups[adGroupIndex].uris[adIndexInAdGroup]); - if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) { + if (adMediaSourceHolders[adGroupIndex].length <= adIndexInAdGroup) { int adCount = adIndexInAdGroup + 1; - adGroupMediaSources[adGroupIndex] = - Arrays.copyOf(adGroupMediaSources[adGroupIndex], adCount); - adGroupTimelines[adGroupIndex] = Arrays.copyOf(adGroupTimelines[adGroupIndex], adCount); + adMediaSourceHolders[adGroupIndex] = + Arrays.copyOf(adMediaSourceHolders[adGroupIndex], adCount); } - MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; - if (mediaSource == null) { - mediaSource = adMediaSourceFactory.createMediaSource(adUri); - adGroupMediaSources[adGroupIndex][adIndexInAdGroup] = mediaSource; - maskingMediaPeriodByAdMediaSource.put(mediaSource, new ArrayList<>()); - prepareChildSource(id, mediaSource); + @Nullable + AdMediaSourceHolder adMediaSourceHolder = + adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]; + if (adMediaSourceHolder == null) { + MediaSource adMediaSource = adMediaSourceFactory.createMediaSource(adUri); + adMediaSourceHolder = new AdMediaSourceHolder(adMediaSource); + adMediaSourceHolders[adGroupIndex][adIndexInAdGroup] = adMediaSourceHolder; + prepareChildSource(id, adMediaSource); } - MaskingMediaPeriod maskingMediaPeriod = - new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); - maskingMediaPeriod.setPrepareErrorListener( - new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); - List mediaPeriods = maskingMediaPeriodByAdMediaSource.get(mediaSource); - if (mediaPeriods == null) { - Object periodUid = - Assertions.checkNotNull(adGroupTimelines[adGroupIndex][adIndexInAdGroup]) - .getUidOfPeriod(/* periodIndex= */ 0); - MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); - maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); - } else { - // Keep track of the masking media period so it can be populated with the real media period - // when the source's info becomes available. - mediaPeriods.add(maskingMediaPeriod); - } - return maskingMediaPeriod; + return adMediaSourceHolder.createMediaPeriod(adUri, id, allocator, startPositionUs); } else { MaskingMediaPeriod mediaPeriod = new MaskingMediaPeriod(contentMediaSource, id, allocator, startPositionUs); @@ -249,12 +229,18 @@ public final class AdsMediaSource extends CompositeMediaSource { @Override public void releasePeriod(MediaPeriod mediaPeriod) { MaskingMediaPeriod maskingMediaPeriod = (MaskingMediaPeriod) mediaPeriod; - List mediaPeriods = - maskingMediaPeriodByAdMediaSource.get(maskingMediaPeriod.mediaSource); - if (mediaPeriods != null) { - mediaPeriods.remove(maskingMediaPeriod); + MediaPeriodId id = maskingMediaPeriod.id; + if (id.isAd()) { + AdMediaSourceHolder adMediaSourceHolder = + Assertions.checkNotNull(adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup]); + adMediaSourceHolder.releaseMediaPeriod(maskingMediaPeriod); + if (adMediaSourceHolder.isInactive()) { + releaseChildSource(id); + adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup] = null; + } + } else { + maskingMediaPeriod.releasePeriod(); } - maskingMediaPeriod.releasePeriod(); } @Override @@ -262,11 +248,9 @@ public final class AdsMediaSource extends CompositeMediaSource { super.releaseSourceInternal(); Assertions.checkNotNull(componentListener).release(); componentListener = null; - maskingMediaPeriodByAdMediaSource.clear(); contentTimeline = null; adPlaybackState = null; - adGroupMediaSources = new MediaSource[0][]; - adGroupTimelines = new Timeline[0][]; + adMediaSourceHolders = new AdMediaSourceHolder[0][]; mainHandler.post(adsLoader::stop); } @@ -276,10 +260,13 @@ public final class AdsMediaSource extends CompositeMediaSource { if (mediaPeriodId.isAd()) { int adGroupIndex = mediaPeriodId.adGroupIndex; int adIndexInAdGroup = mediaPeriodId.adIndexInAdGroup; - onAdSourceInfoRefreshed(mediaSource, adGroupIndex, adIndexInAdGroup, timeline); + Assertions.checkNotNull(adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]) + .handleSourceInfoRefresh(timeline); } else { - onContentSourceInfoRefreshed(timeline); + Assertions.checkArgument(timeline.getPeriodCount() == 1); + contentTimeline = timeline; } + maybeUpdateSourceInfo(); } @Override @@ -294,42 +281,17 @@ public final class AdsMediaSource extends CompositeMediaSource { private void onAdPlaybackState(AdPlaybackState adPlaybackState) { if (this.adPlaybackState == null) { - adGroupMediaSources = new MediaSource[adPlaybackState.adGroupCount][]; - Arrays.fill(adGroupMediaSources, new MediaSource[0]); - adGroupTimelines = new Timeline[adPlaybackState.adGroupCount][]; - Arrays.fill(adGroupTimelines, new Timeline[0]); + adMediaSourceHolders = new AdMediaSourceHolder[adPlaybackState.adGroupCount][]; + Arrays.fill(adMediaSourceHolders, new AdMediaSourceHolder[0]); } this.adPlaybackState = adPlaybackState; maybeUpdateSourceInfo(); } - private void onContentSourceInfoRefreshed(Timeline timeline) { - Assertions.checkArgument(timeline.getPeriodCount() == 1); - contentTimeline = timeline; - maybeUpdateSourceInfo(); - } - - private void onAdSourceInfoRefreshed(MediaSource mediaSource, int adGroupIndex, - int adIndexInAdGroup, Timeline timeline) { - Assertions.checkArgument(timeline.getPeriodCount() == 1); - adGroupTimelines[adGroupIndex][adIndexInAdGroup] = timeline; - List mediaPeriods = maskingMediaPeriodByAdMediaSource.remove(mediaSource); - if (mediaPeriods != null) { - Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); - for (int i = 0; i < mediaPeriods.size(); i++) { - MaskingMediaPeriod mediaPeriod = mediaPeriods.get(i); - MediaPeriodId adSourceMediaPeriodId = - new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); - mediaPeriod.createPeriod(adSourceMediaPeriodId); - } - } - maybeUpdateSourceInfo(); - } - private void maybeUpdateSourceInfo() { - Timeline contentTimeline = this.contentTimeline; + @Nullable Timeline contentTimeline = this.contentTimeline; if (adPlaybackState != null && contentTimeline != null) { - adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurations(adGroupTimelines, period)); + adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs()); Timeline timeline = adPlaybackState.adGroupCount == 0 ? contentTimeline @@ -338,19 +300,16 @@ public final class AdsMediaSource extends CompositeMediaSource { } } - private static long[][] getAdDurations( - @NullableType Timeline[][] adTimelines, Timeline.Period period) { - long[][] adDurations = new long[adTimelines.length][]; - for (int i = 0; i < adTimelines.length; i++) { - adDurations[i] = new long[adTimelines[i].length]; - for (int j = 0; j < adTimelines[i].length; j++) { - adDurations[i][j] = - adTimelines[i][j] == null - ? C.TIME_UNSET - : adTimelines[i][j].getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + private long[][] getAdDurationsUs() { + long[][] adDurationsUs = new long[adMediaSourceHolders.length][]; + for (int i = 0; i < adMediaSourceHolders.length; i++) { + adDurationsUs[i] = new long[adMediaSourceHolders[i].length]; + for (int j = 0; j < adMediaSourceHolders[i].length; j++) { + @Nullable AdMediaSourceHolder holder = adMediaSourceHolders[i][j]; + adDurationsUs[i][j] = holder == null ? C.TIME_UNSET : holder.getDurationUs(); } } - return adDurations; + return adDurationsUs; } /** Listener for component events. All methods are called on the main thread. */ @@ -436,4 +395,61 @@ public final class AdsMediaSource extends CompositeMediaSource { () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception)); } } + + private final class AdMediaSourceHolder { + + private final MediaSource adMediaSource; + private final List activeMediaPeriods; + + @MonotonicNonNull private Timeline timeline; + + public AdMediaSourceHolder(MediaSource adMediaSource) { + this.adMediaSource = adMediaSource; + activeMediaPeriods = new ArrayList<>(); + } + + public MediaPeriod createMediaPeriod( + Uri adUri, MediaPeriodId id, Allocator allocator, long startPositionUs) { + MaskingMediaPeriod maskingMediaPeriod = + new MaskingMediaPeriod(adMediaSource, id, allocator, startPositionUs); + maskingMediaPeriod.setPrepareErrorListener( + new AdPrepareErrorListener(adUri, id.adGroupIndex, id.adIndexInAdGroup)); + activeMediaPeriods.add(maskingMediaPeriod); + if (timeline != null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); + maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); + } + return maskingMediaPeriod; + } + + public void handleSourceInfoRefresh(Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + if (this.timeline == null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + for (int i = 0; i < activeMediaPeriods.size(); i++) { + MaskingMediaPeriod mediaPeriod = activeMediaPeriods.get(i); + MediaPeriodId adSourceMediaPeriodId = + new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); + mediaPeriod.createPeriod(adSourceMediaPeriodId); + } + } + this.timeline = timeline; + } + + public long getDurationUs() { + return timeline == null + ? C.TIME_UNSET + : timeline.getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + } + + public void releaseMediaPeriod(MaskingMediaPeriod maskingMediaPeriod) { + activeMediaPeriods.remove(maskingMediaPeriod); + maskingMediaPeriod.releasePeriod(); + } + + public boolean isInactive() { + return activeMediaPeriods.isEmpty(); + } + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java new file mode 100644 index 0000000000..77eb628f28 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -0,0 +1,216 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.ads; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; +import static org.robolectric.annotation.LooperMode.Mode.PAUSED; + +import android.net.Uri; +import android.os.Looper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; +import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener; +import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.upstream.Allocator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.annotation.LooperMode; + +/** Unit tests for {@link AdsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +@LooperMode(PAUSED) +public final class AdsMediaSourceTest { + + private static final long PREROLL_AD_DURATION_US = 10 * C.MICROS_PER_SECOND; + private static final Timeline PREROLL_AD_TIMELINE = + new SinglePeriodTimeline( + PREROLL_AD_DURATION_US, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false); + private static final Object PREROLL_AD_PERIOD_UID = + PREROLL_AD_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); + + private static final long CONTENT_DURATION_US = 30 * C.MICROS_PER_SECOND; + private static final Timeline CONTENT_TIMELINE = + new SinglePeriodTimeline( + CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false); + private static final Object CONTENT_PERIOD_UID = + CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); + + private static final AdPlaybackState AD_PLAYBACK_STATE = + new AdPlaybackState(/* adGroupTimesUs...= */ 0) + .withContentDurationUs(CONTENT_DURATION_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withAdResumePositionUs(/* adResumePositionUs= */ 0); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private FakeMediaSource contentMediaSource; + private FakeMediaSource prerollAdMediaSource; + @Mock private MediaSourceCaller mockMediaSourceCaller; + private AdsMediaSource adsMediaSource; + + @Before + public void setUp() { + // Set up content and ad media sources, passing a null timeline so tests can simulate setting it + // later. + contentMediaSource = new FakeMediaSource(/* timeline= */ null); + prerollAdMediaSource = new FakeMediaSource(/* timeline= */ null); + MediaSourceFactory adMediaSourceFactory = mock(MediaSourceFactory.class); + when(adMediaSourceFactory.createMediaSource(any(Uri.class))).thenReturn(prerollAdMediaSource); + + // Prepare the AdsMediaSource and capture its ads loader listener. + AdsLoader mockAdsLoader = mock(AdsLoader.class); + AdViewProvider mockAdViewProvider = mock(AdViewProvider.class); + ArgumentCaptor eventListenerArgumentCaptor = + ArgumentCaptor.forClass(AdsLoader.EventListener.class); + adsMediaSource = + new AdsMediaSource( + contentMediaSource, adMediaSourceFactory, mockAdsLoader, mockAdViewProvider); + adsMediaSource.prepareSource(mockMediaSourceCaller, /* mediaTransferListener= */ null); + shadowOf(Looper.getMainLooper()).idle(); + verify(mockAdsLoader).start(eventListenerArgumentCaptor.capture(), eq(mockAdViewProvider)); + + // Simulate loading a preroll ad. + AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue(); + adsLoaderEventListener.onAdPlaybackState(AD_PLAYBACK_STATE); + shadowOf(Looper.getMainLooper()).idle(); + } + + @Test + public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfo() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(prerollAdMediaSource.isPrepared()).isTrue(); + verify(mockMediaSourceCaller) + .onSourceInfoRefreshed( + adsMediaSource, new SinglePeriodAdTimeline(CONTENT_TIMELINE, AD_PLAYBACK_STATE)); + } + + @Test + public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfoWithAdMediaSourceInfo() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mockMediaSourceCaller) + .onSourceInfoRefreshed( + adsMediaSource, + new SinglePeriodAdTimeline( + CONTENT_TIMELINE, + AD_PLAYBACK_STATE.withAdDurationsUs(new long[][] {{PREROLL_AD_DURATION_US}}))); + } + + @Test + public void createPeriod_createsChildPrerollAdMediaPeriod() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + + prerollAdMediaSource.assertMediaPeriodCreated( + new MediaPeriodId(PREROLL_AD_PERIOD_UID, /* windowSequenceNumber= */ 0)); + } + + @Test + public void createPeriod_createsChildContentMediaPeriod() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + adsMediaSource.createPeriod( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + + contentMediaSource.assertMediaPeriodCreated( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0)); + } + + @Test + public void releasePeriod_releasesChildMediaPeriodsAndSources() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + MediaPeriod prerollAdMediaPeriod = + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + MediaPeriod contentMediaPeriod = + adsMediaSource.createPeriod( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + adsMediaSource.releasePeriod(prerollAdMediaPeriod); + + prerollAdMediaSource.assertReleased(); + + adsMediaSource.releasePeriod(contentMediaPeriod); + adsMediaSource.releaseSource(mockMediaSourceCaller); + shadowOf(Looper.getMainLooper()).idle(); + prerollAdMediaSource.assertReleased(); + contentMediaSource.assertReleased(); + } +} From 190d81f0f6994a691847ff5a209a4aa213366669 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Apr 2020 12:54:52 +0100 Subject: [PATCH 0319/1052] Noop naming generalization for H265Reader This change generalizes the concept of "reading parameter sets" to "reading prefix NAL units", ahead of a change that will treat AUD and suffix SEI NAL units in the same way. The change also introduces some static isXxxNalUnit methods for clarity. Issue: #7113 PiperOrigin-RevId: 307376967 --- .../exoplayer2/extractor/ts/H265Reader.java | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index b361e4972c..97175a392f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -59,7 +59,7 @@ public final class H265Reader implements ElementaryStreamReader { private final NalUnitTargetBuffer sps; private final NalUnitTargetBuffer pps; private final NalUnitTargetBuffer prefixSei; - private final NalUnitTargetBuffer suffixSei; // TODO: Are both needed? + private final NalUnitTargetBuffer suffixSei; private long totalBytesWritten; // Per packet state that gets reset at the start of each packet. @@ -397,17 +397,17 @@ public final class H265Reader implements ElementaryStreamReader { private final TrackOutput output; // Per NAL unit state. A sample consists of one or more NAL units. - private long nalUnitStartPosition; + private long nalUnitPosition; private boolean nalUnitHasKeyframeData; private int nalUnitBytesRead; private long nalUnitTimeUs; private boolean lookingForFirstSliceFlag; private boolean isFirstSlice; - private boolean isFirstParameterSet; + private boolean isFirstPrefixNalUnit; // Per sample state that gets reset at the start of each sample. private boolean readingSample; - private boolean writingParameterSets; + private boolean readingPrefix; private long samplePosition; private long sampleTimeUs; private boolean sampleIsKeyframe; @@ -419,31 +419,29 @@ public final class H265Reader implements ElementaryStreamReader { public void reset() { lookingForFirstSliceFlag = false; isFirstSlice = false; - isFirstParameterSet = false; + isFirstPrefixNalUnit = false; readingSample = false; - writingParameterSets = false; + readingPrefix = false; } public void startNalUnit( long position, int offset, int nalUnitType, long pesTimeUs, boolean hasOutputFormat) { isFirstSlice = false; - isFirstParameterSet = false; + isFirstPrefixNalUnit = false; nalUnitTimeUs = pesTimeUs; nalUnitBytesRead = 0; - nalUnitStartPosition = position; + nalUnitPosition = position; - if (nalUnitType >= VPS_NUT) { - if (!writingParameterSets && readingSample) { - // This is a non-VCL NAL unit, so flush the previous sample. + if (!isVclBodyNalUnit(nalUnitType)) { + if (readingSample && !readingPrefix) { if (hasOutputFormat) { outputSample(offset); } readingSample = false; } - if (nalUnitType <= PPS_NUT) { - // This sample will have parameter sets at the start. - isFirstParameterSet = !writingParameterSets; - writingParameterSets = true; + if (isPrefixNalUnit(nalUnitType)) { + isFirstPrefixNalUnit = !readingPrefix; + readingPrefix = true; } } @@ -465,30 +463,40 @@ public final class H265Reader implements ElementaryStreamReader { } public void endNalUnit(long position, int offset, boolean hasOutputFormat) { - if (writingParameterSets && isFirstSlice) { + if (readingPrefix && isFirstSlice) { // This sample has parameter sets. Reset the key-frame flag based on the first slice. sampleIsKeyframe = nalUnitHasKeyframeData; - writingParameterSets = false; - } else if (isFirstParameterSet || isFirstSlice) { + readingPrefix = false; + } else if (isFirstPrefixNalUnit || isFirstSlice) { // This NAL unit is at the start of a new sample (access unit). if (hasOutputFormat && readingSample) { // Output the sample ending before this NAL unit. - int nalUnitLength = (int) (position - nalUnitStartPosition); + int nalUnitLength = (int) (position - nalUnitPosition); outputSample(offset + nalUnitLength); } - samplePosition = nalUnitStartPosition; + samplePosition = nalUnitPosition; sampleTimeUs = nalUnitTimeUs; - readingSample = true; sampleIsKeyframe = nalUnitHasKeyframeData; + readingSample = true; } } private void outputSample(int offset) { @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; - int size = (int) (nalUnitStartPosition - samplePosition); + int size = (int) (nalUnitPosition - samplePosition); output.sampleMetadata(sampleTimeUs, flags, size, offset, null); } - } + /** Returns whether a NAL unit type is one that occurs before any VCL NAL units in a sample. */ + private static boolean isPrefixNalUnit(int nalUnitType) { + // TODO: Include AUD_NUT and PREFIX_SEI_NUT + return VPS_NUT <= nalUnitType && nalUnitType <= PPS_NUT; + } + /** Returns whether a NAL unit type is one that occurs in the VLC body of a sample. */ + private static boolean isVclBodyNalUnit(int nalUnitType) { + // TODO: Include SUFFIX_SEI_NUT + return nalUnitType < VPS_NUT; + } + } } From a697905cfb95a97a1ff467d94ec6879d1ed89bab Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Apr 2020 13:25:43 +0100 Subject: [PATCH 0320/1052] Fix H265Reader to correctly output SEI and AUD NAL units Issue: #7113 PiperOrigin-RevId: 307380133 --- RELEASENOTES.md | 2 ++ .../android/exoplayer2/extractor/ts/H265Reader.java | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 37f7d884f8..fef940b552 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -13,6 +13,8 @@ `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as the main adaptation sets to which they refer. Trick play tracks are marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. +* MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 + samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). ### 2.11.4 (2020-04-08) ### diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index 97175a392f..b4007ea4a4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -41,6 +41,7 @@ public final class H265Reader implements ElementaryStreamReader { private static final int VPS_NUT = 32; private static final int SPS_NUT = 33; private static final int PPS_NUT = 34; + private static final int AUD_NUT = 35; private static final int PREFIX_SEI_NUT = 39; private static final int SUFFIX_SEI_NUT = 40; @@ -445,7 +446,7 @@ public final class H265Reader implements ElementaryStreamReader { } } - // Look for the flag if this NAL unit contains a slice_segment_layer_rbsp. + // Look for the first slice flag if this NAL unit contains a slice_segment_layer_rbsp. nalUnitHasKeyframeData = (nalUnitType >= BLA_W_LP && nalUnitType <= CRA_NUT); lookingForFirstSliceFlag = nalUnitHasKeyframeData || nalUnitType <= RASL_R; } @@ -489,14 +490,12 @@ public final class H265Reader implements ElementaryStreamReader { /** Returns whether a NAL unit type is one that occurs before any VCL NAL units in a sample. */ private static boolean isPrefixNalUnit(int nalUnitType) { - // TODO: Include AUD_NUT and PREFIX_SEI_NUT - return VPS_NUT <= nalUnitType && nalUnitType <= PPS_NUT; + return (VPS_NUT <= nalUnitType && nalUnitType <= AUD_NUT) || nalUnitType == PREFIX_SEI_NUT; } /** Returns whether a NAL unit type is one that occurs in the VLC body of a sample. */ private static boolean isVclBodyNalUnit(int nalUnitType) { - // TODO: Include SUFFIX_SEI_NUT - return nalUnitType < VPS_NUT; + return nalUnitType < VPS_NUT || nalUnitType == SUFFIX_SEI_NUT; } } } From d9703358acf47db9180d9b4bc5d225801d24b959 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 20 Apr 2020 17:11:13 +0100 Subject: [PATCH 0321/1052] Use anti-aliasing and bitmap filtering for bitmap subtitles issue:#6950 PiperOrigin-RevId: 307411067 --- RELEASENOTES.md | 4 +++ .../exoplayer2/ui/SubtitlePainter.java | 25 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fef940b552..742b2828e7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Release notes # ### Next release ### @@ -15,6 +16,9 @@ marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). +* Text + * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles + ([#6950](https://github.com/google/ExoPlayer/pull/6950)). ### 2.11.4 (2020-04-08) ### diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java index 76768804df..714d40ff9a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java @@ -64,7 +64,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final float spacingAdd; private final TextPaint textPaint; - private final Paint paint; + private final Paint windowPaint; + private final Paint bitmapPaint; // Previous input variables. @Nullable private CharSequence cueText; @@ -122,9 +123,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; textPaint.setAntiAlias(true); textPaint.setSubpixelText(true); - paint = new Paint(); - paint.setAntiAlias(true); - paint.setStyle(Style.FILL); + windowPaint = new Paint(); + windowPaint.setAntiAlias(true); + windowPaint.setStyle(Style.FILL); + + bitmapPaint = new Paint(); + bitmapPaint.setAntiAlias(true); + bitmapPaint.setFilterBitmap(true); } /** @@ -415,9 +420,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; canvas.translate(textLeft, textTop); if (Color.alpha(windowColor) > 0) { - paint.setColor(windowColor); - canvas.drawRect(-textPaddingX, 0, layout.getWidth() + textPaddingX, layout.getHeight(), - paint); + windowPaint.setColor(windowColor); + canvas.drawRect( + -textPaddingX, + 0, + textLayout.getWidth() + textPaddingX, + textLayout.getHeight(), + windowPaint); } if (edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) { @@ -451,7 +460,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @RequiresNonNull({"cueBitmap", "bitmapRect"}) private void drawBitmapLayout(Canvas canvas) { - canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, /* paint= */ null); + canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, bitmapPaint); } /** From b954a5aa5f38fe69abbb58313125dafc54267842 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 21 Apr 2020 18:22:35 +0100 Subject: [PATCH 0322/1052] Fix timestamp rounding error in fMP4 extractor. The sample timestamps are currently rounded to milliseconds, only to be multiplied by 1000 later. This causes rounding errors where the sample timestamps don't match the timestamps in the seek table (which are already in microseconds). issue:#7086 PiperOrigin-RevId: 307630559 --- .../extractor/mp4/FragmentedMp4Extractor.java | 25 ++- .../extractor/mp4/TrackFragment.java | 26 +-- .../mp4/sample_ac4_fragmented.mp4.0.dump | 2 +- .../mp4/sample_ac4_fragmented.mp4.1.dump | 2 +- .../mp4/sample_ac4_fragmented.mp4.2.dump | 2 +- .../mp4/sample_ac4_protected.mp4.0.dump | 2 +- .../mp4/sample_ac4_protected.mp4.1.dump | 2 +- .../mp4/sample_ac4_protected.mp4.2.dump | 2 +- .../assets/mp4/sample_fragmented.mp4.0.dump | 150 +++++++++--------- .../mp4/sample_fragmented_seekable.mp4.0.dump | 150 +++++++++--------- .../mp4/sample_fragmented_seekable.mp4.1.dump | 122 +++++++------- .../mp4/sample_fragmented_seekable.mp4.2.dump | 92 +++++------ .../mp4/sample_fragmented_seekable.mp4.3.dump | 62 ++++---- .../mp4/sample_fragmented_sei.mp4.0.dump | 150 +++++++++--------- 14 files changed, 395 insertions(+), 394 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 42aeab64b3..c0d1581c39 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -962,20 +962,20 @@ public class FragmentedMp4Extractor implements Extractor { // Offset to the entire video timeline. In the presence of B-frames this is usually used to // ensure that the first frame's presentation timestamp is zero. - long edtsOffset = 0; + long edtsOffsetUs = 0; // Currently we only support a single edit that moves the entire media timeline (indicated by // duration == 0). Other uses of edit lists are uncommon and unsupported. if (track.editListDurations != null && track.editListDurations.length == 1 && track.editListDurations[0] == 0) { - edtsOffset = + edtsOffsetUs = Util.scaleLargeTimestamp( - track.editListMediaTimes[0], C.MILLIS_PER_SECOND, track.timescale); + track.editListMediaTimes[0], C.MICROS_PER_SECOND, track.timescale); } int[] sampleSizeTable = fragment.sampleSizeTable; - int[] sampleCompositionTimeOffsetTable = fragment.sampleCompositionTimeOffsetTable; - long[] sampleDecodingTimeTable = fragment.sampleDecodingTimeTable; + int[] sampleCompositionTimeOffsetUsTable = fragment.sampleCompositionTimeOffsetUsTable; + long[] sampleDecodingTimeUsTable = fragment.sampleDecodingTimeUsTable; boolean[] sampleIsSyncFrameTable = fragment.sampleIsSyncFrameTable; boolean workaroundEveryVideoFrameIsSyncFrame = track.type == C.TRACK_TYPE_VIDEO @@ -999,13 +999,13 @@ public class FragmentedMp4Extractor implements Extractor { // here, because unsigned integers will still be parsed correctly (unless their top bit is // set, which is never true in practice because sample offsets are always small). int sampleOffset = trun.readInt(); - sampleCompositionTimeOffsetTable[i] = - (int) ((sampleOffset * C.MILLIS_PER_SECOND) / timescale); + sampleCompositionTimeOffsetUsTable[i] = + (int) ((sampleOffset * C.MICROS_PER_SECOND) / timescale); } else { - sampleCompositionTimeOffsetTable[i] = 0; + sampleCompositionTimeOffsetUsTable[i] = 0; } - sampleDecodingTimeTable[i] = - Util.scaleLargeTimestamp(cumulativeTime, C.MILLIS_PER_SECOND, timescale) - edtsOffset; + sampleDecodingTimeUsTable[i] = + Util.scaleLargeTimestamp(cumulativeTime, C.MICROS_PER_SECOND, timescale) - edtsOffsetUs; sampleSizeTable[i] = sampleSize; sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0 && (!workaroundEveryVideoFrameIsSyncFrame || i == 0); @@ -1291,7 +1291,7 @@ public class FragmentedMp4Extractor implements Extractor { Track track = currentTrackBundle.track; TrackOutput output = currentTrackBundle.output; int sampleIndex = currentTrackBundle.currentSampleIndex; - long sampleTimeUs = fragment.getSamplePresentationTime(sampleIndex) * 1000L; + long sampleTimeUs = fragment.getSamplePresentationTimeUs(sampleIndex); if (timestampAdjuster != null) { sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); } @@ -1535,10 +1535,9 @@ public class FragmentedMp4Extractor implements Extractor { * @param timeUs The seek time, in microseconds. */ public void seek(long timeUs) { - long timeMs = C.usToMs(timeUs); int searchIndex = currentSampleIndex; while (searchIndex < fragment.sampleCount - && fragment.getSamplePresentationTime(searchIndex) < timeMs) { + && fragment.getSamplePresentationTimeUs(searchIndex) < timeUs) { if (fragment.sampleIsSyncFrameTable[searchIndex]) { firstSampleToOutputIndex = searchIndex; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java index 51ec2bf282..0272e8e338 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java @@ -60,14 +60,10 @@ import java.io.IOException; * The size of each sample in the fragment. */ public int[] sampleSizeTable; - /** - * The composition time offset of each sample in the fragment. - */ - public int[] sampleCompositionTimeOffsetTable; - /** - * The decoding time of each sample in the fragment. - */ - public long[] sampleDecodingTimeTable; + /** The composition time offset of each sample in the fragment, in microseconds. */ + public int[] sampleCompositionTimeOffsetUsTable; + /** The decoding time of each sample in the fragment, in microseconds. */ + public long[] sampleDecodingTimeUsTable; /** * Indicates which samples are sync frames. */ @@ -139,8 +135,8 @@ import java.io.IOException; // likely. The choice of 25% is relatively arbitrary. int tableSize = (sampleCount * 125) / 100; sampleSizeTable = new int[tableSize]; - sampleCompositionTimeOffsetTable = new int[tableSize]; - sampleDecodingTimeTable = new long[tableSize]; + sampleCompositionTimeOffsetUsTable = new int[tableSize]; + sampleDecodingTimeUsTable = new long[tableSize]; sampleIsSyncFrameTable = new boolean[tableSize]; sampleHasSubsampleEncryptionTable = new boolean[tableSize]; } @@ -186,8 +182,14 @@ import java.io.IOException; sampleEncryptionDataNeedsFill = false; } - public long getSamplePresentationTime(int index) { - return sampleDecodingTimeTable[index] + sampleCompositionTimeOffsetTable[index]; + /** + * Returns the sample presentation timestamp in microseconds. + * + * @param index The sample index. + * @return The presentation timestamps of this sample in microseconds. + */ + public long getSamplePresentationTimeUs(int index) { + return sampleDecodingTimeUsTable[index] + sampleCompositionTimeOffsetUsTable[index]; } /** Returns whether the sample at the given index has a subsample encryption table. */ diff --git a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump index 505c85e51f..b2412d09ff 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump @@ -81,7 +81,7 @@ track 0: flags = 1 data = length 520, hash FEE56928 sample 13: - time = 520000 + time = 519999 flags = 1 data = length 599, hash 41F496C5 sample 14: diff --git a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump index 8bee343bd9..41844c32a3 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump @@ -57,7 +57,7 @@ track 0: flags = 1 data = length 520, hash FEE56928 sample 7: - time = 520000 + time = 519999 flags = 1 data = length 599, hash 41F496C5 sample 8: diff --git a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump index ee1cf91a57..0f00ba9c5b 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump @@ -33,7 +33,7 @@ track 0: flags = 1 data = length 520, hash FEE56928 sample 1: - time = 520000 + time = 519999 flags = 1 data = length 599, hash 41F496C5 sample 2: diff --git a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump index 02db599cd7..c0e7d2a38d 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump @@ -107,7 +107,7 @@ track 0: crypto mode = 1 encryption key = length 16, hash 9FDDEA52 sample 13: - time = 520000 + time = 519999 flags = 1073741825 data = length 616, hash 3F657E23 crypto mode = 1 diff --git a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump index 8b45dd0a50..7886fc21ac 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump @@ -71,7 +71,7 @@ track 0: crypto mode = 1 encryption key = length 16, hash 9FDDEA52 sample 7: - time = 520000 + time = 519999 flags = 1073741825 data = length 616, hash 3F657E23 crypto mode = 1 diff --git a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump index a6be34dec7..e726932cb0 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump @@ -35,7 +35,7 @@ track 0: crypto mode = 1 encryption key = length 16, hash 9FDDEA52 sample 1: - time = 520000 + time = 519999 flags = 1073741825 data = length 616, hash 3F657E23 crypto mode = 1 diff --git a/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump index 65f59d78b5..d2b197286b 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -181,183 +181,183 @@ track 1: flags = 1 data = length 18, hash 96519432 sample 1: - time = 23000 + time = 23219 flags = 1 data = length 4, hash EE9DF sample 2: - time = 46000 + time = 46439 flags = 1 data = length 4, hash EEDBF sample 3: - time = 69000 + time = 69659 flags = 1 data = length 157, hash E2F078F4 sample 4: - time = 92000 + time = 92879 flags = 1 data = length 371, hash B9471F94 sample 5: - time = 116000 + time = 116099 flags = 1 data = length 373, hash 2AB265CB sample 6: - time = 139000 + time = 139319 flags = 1 data = length 402, hash 1295477C sample 7: - time = 162000 + time = 162539 flags = 1 data = length 455, hash 2D8146C8 sample 8: - time = 185000 + time = 185759 flags = 1 data = length 434, hash F2C5D287 sample 9: - time = 208000 + time = 208979 flags = 1 data = length 450, hash 84143FCD sample 10: - time = 232000 + time = 232199 flags = 1 data = length 429, hash EF769D50 sample 11: - time = 255000 + time = 255419 flags = 1 data = length 450, hash EC3DE692 sample 12: - time = 278000 + time = 278639 flags = 1 data = length 447, hash 3E519E13 sample 13: - time = 301000 + time = 301859 flags = 1 data = length 457, hash 1E4F23A0 sample 14: - time = 325000 + time = 325079 flags = 1 data = length 447, hash A439EA97 sample 15: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 16: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 17: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 18: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 19: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 20: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 21: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 22: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 23: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 24: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 25: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 26: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 27: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 28: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 29: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 30: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 31: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 32: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 33: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 34: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 35: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 36: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 37: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 38: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 39: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 40: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 41: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 42: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 43: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 44: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 45: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump index 27838bd2a8..8df0f881aa 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -181,183 +181,183 @@ track 1: flags = 1 data = length 18, hash 96519432 sample 1: - time = 23000 + time = 23219 flags = 1 data = length 4, hash EE9DF sample 2: - time = 46000 + time = 46439 flags = 1 data = length 4, hash EEDBF sample 3: - time = 69000 + time = 69659 flags = 1 data = length 157, hash E2F078F4 sample 4: - time = 92000 + time = 92879 flags = 1 data = length 371, hash B9471F94 sample 5: - time = 116000 + time = 116099 flags = 1 data = length 373, hash 2AB265CB sample 6: - time = 139000 + time = 139319 flags = 1 data = length 402, hash 1295477C sample 7: - time = 162000 + time = 162539 flags = 1 data = length 455, hash 2D8146C8 sample 8: - time = 185000 + time = 185759 flags = 1 data = length 434, hash F2C5D287 sample 9: - time = 208000 + time = 208979 flags = 1 data = length 450, hash 84143FCD sample 10: - time = 232000 + time = 232199 flags = 1 data = length 429, hash EF769D50 sample 11: - time = 255000 + time = 255419 flags = 1 data = length 450, hash EC3DE692 sample 12: - time = 278000 + time = 278639 flags = 1 data = length 447, hash 3E519E13 sample 13: - time = 301000 + time = 301859 flags = 1 data = length 457, hash 1E4F23A0 sample 14: - time = 325000 + time = 325079 flags = 1 data = length 447, hash A439EA97 sample 15: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 16: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 17: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 18: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 19: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 20: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 21: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 22: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 23: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 24: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 25: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 26: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 27: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 28: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 29: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 30: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 31: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 32: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 33: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 34: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 35: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 36: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 37: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 38: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 39: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 40: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 41: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 42: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 43: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 44: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 45: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump index ea6deafcad..2e80647199 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -177,127 +177,127 @@ track 1: total output bytes = 13359 sample count = 31 sample 0: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 1: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 2: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 3: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 4: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 5: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 6: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 7: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 8: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 9: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 10: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 11: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 12: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 13: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 14: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 15: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 16: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 17: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 18: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 19: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 20: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 21: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 22: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 23: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 24: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 25: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 26: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 27: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 28: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 29: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 30: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump index d14025e0b1..1715795320 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -177,67 +177,67 @@ track 1: total output bytes = 6804 sample count = 16 sample 0: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 1: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 2: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 3: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 4: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 5: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 6: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 7: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 8: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 9: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 10: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 11: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 12: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 13: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 14: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 15: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump index d08a1e93ad..fcd968440f 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -177,7 +177,7 @@ track 1: total output bytes = 10 sample count = 1 sample 0: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump index d596a77f78..3967f39251 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -181,183 +181,183 @@ track 1: flags = 1 data = length 18, hash 96519432 sample 1: - time = 23000 + time = 23219 flags = 1 data = length 4, hash EE9DF sample 2: - time = 46000 + time = 46439 flags = 1 data = length 4, hash EEDBF sample 3: - time = 69000 + time = 69659 flags = 1 data = length 157, hash E2F078F4 sample 4: - time = 92000 + time = 92879 flags = 1 data = length 371, hash B9471F94 sample 5: - time = 116000 + time = 116099 flags = 1 data = length 373, hash 2AB265CB sample 6: - time = 139000 + time = 139319 flags = 1 data = length 402, hash 1295477C sample 7: - time = 162000 + time = 162539 flags = 1 data = length 455, hash 2D8146C8 sample 8: - time = 185000 + time = 185759 flags = 1 data = length 434, hash F2C5D287 sample 9: - time = 208000 + time = 208979 flags = 1 data = length 450, hash 84143FCD sample 10: - time = 232000 + time = 232199 flags = 1 data = length 429, hash EF769D50 sample 11: - time = 255000 + time = 255419 flags = 1 data = length 450, hash EC3DE692 sample 12: - time = 278000 + time = 278639 flags = 1 data = length 447, hash 3E519E13 sample 13: - time = 301000 + time = 301859 flags = 1 data = length 457, hash 1E4F23A0 sample 14: - time = 325000 + time = 325079 flags = 1 data = length 447, hash A439EA97 sample 15: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 16: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 17: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 18: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 19: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 20: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 21: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 22: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 23: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 24: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 25: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 26: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 27: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 28: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 29: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 30: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 31: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 32: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 33: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 34: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 35: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 36: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 37: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 38: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 39: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 40: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 41: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 42: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 43: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 44: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 45: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE track 3: From 7ee08f09d28d4d3e13cdaf13689f463dd9d1bd5c Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 23 Apr 2020 14:57:51 +0100 Subject: [PATCH 0323/1052] Fix AdsMediaSource parameter when reporting load error PiperOrigin-RevId: 308041841 --- .../google/android/exoplayer2/source/ads/AdsMediaSource.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 34f0d496a2..4ecef1bd5b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source.ads; import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -358,7 +359,7 @@ public final class AdsMediaSource extends CompositeMediaSource { dataSpec.uri, /* responseHeaders= */ Collections.emptyMap(), C.DATA_TYPE_AD, - C.TRACK_TYPE_UNKNOWN, + /* elapsedRealtimeMs= */ SystemClock.elapsedRealtime(), /* loadDurationMs= */ 0, /* bytesLoaded= */ 0, error, From 49e5e66033c497fdd26c756d97c94b595ec35eb6 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 24 Apr 2020 13:32:05 +0100 Subject: [PATCH 0324/1052] Fix NPE when reading from a SampleQueue from a loading thread Issue: #7273 PiperOrigin-RevId: 308238035 --- RELEASENOTES.md | 3 ++- .../source/ProgressiveMediaPeriod.java | 3 ++- .../exoplayer2/source/SampleQueue.java | 8 +++--- .../source/chunk/ChunkSampleStream.java | 11 ++++++-- .../exoplayer2/source/SampleQueueTest.java | 27 +++++++++++++++---- .../source/dash/PlayerEmsgHandler.java | 5 +++- .../source/hls/HlsSampleStreamWrapper.java | 10 +++++-- 7 files changed, 52 insertions(+), 15 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 742b2828e7..89d630527a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,4 +1,3 @@ -<<<<<<< HEAD # Release notes # ### Next release ### @@ -14,6 +13,8 @@ `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as the main adaptation sets to which they refer. Trick play tracks are marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. + * Fix assertion failure in `SampleQueue` when playing DASH streams with + EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). * Text diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 966a58bf5f..efdfdf15a8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -679,7 +679,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return sampleQueues[i]; } } - SampleQueue trackOutput = new SampleQueue(allocator, drmSessionManager); + SampleQueue trackOutput = new SampleQueue( + allocator, /* playbackLooper= */ handler.getLooper(), drmSessionManager); trackOutput.setUpstreamFormatChangeListener(this); @NullableType TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index b5cfe6ed72..c63b755f4a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -55,6 +55,7 @@ public class SampleQueue implements TrackOutput { private final SampleExtrasHolder extrasHolder; private final DrmSessionManager drmSessionManager; private UpstreamFormatChangedListener upstreamFormatChangeListener; + private final Looper playbackLooper; @Nullable private Format downstreamFormat; @Nullable private DrmSession currentDrmSession; @@ -91,11 +92,13 @@ public class SampleQueue implements TrackOutput { * Creates a sample queue. * * @param allocator An {@link Allocator} from which allocations for sample data can be obtained. + * @param playbackLooper The looper associated with the media playback thread. * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} * from. The created instance does not take ownership of this {@link DrmSessionManager}. */ - public SampleQueue(Allocator allocator, DrmSessionManager drmSessionManager) { + public SampleQueue(Allocator allocator, Looper playbackLooper, DrmSessionManager drmSessionManager) { sampleDataQueue = new SampleDataQueue(allocator); + this.playbackLooper = playbackLooper; this.drmSessionManager = drmSessionManager; extrasHolder = new SampleExtrasHolder(); capacity = SAMPLE_CAPACITY_INCREMENT; @@ -789,8 +792,7 @@ public class SampleQueue implements TrackOutput { } // Ensure we acquire the new session before releasing the previous one in case the same session // is being used for both DrmInitData. - DrmSession previousSession = currentDrmSession; - Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); + @Nullable DrmSession previousSession = currentDrmSession; currentDrmSession = newDrmInitData != null ? drmSessionManager.acquireSession(playbackLooper, newDrmInitData) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index db555b136f..e2278d7f95 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.chunk; +import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -130,13 +131,19 @@ public class ChunkSampleStream implements SampleStream, S int[] trackTypes = new int[1 + embeddedTrackCount]; SampleQueue[] sampleQueues = new SampleQueue[1 + embeddedTrackCount]; - primarySampleQueue = new SampleQueue(allocator, drmSessionManager); + primarySampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + drmSessionManager); trackTypes[0] = primaryTrackType; sampleQueues[0] = primarySampleQueue; for (int i = 0; i < embeddedTrackCount; i++) { SampleQueue sampleQueue = - new SampleQueue(allocator, DrmSessionManager.getDummyDrmSessionManager()); + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + DrmSessionManager.getDummyDrmSessionManager()); embeddedSampleQueues[i] = sampleQueue; sampleQueues[i + 1] = sampleQueue; trackTypes[i + 1] = embeddedTrackTypes[i]; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index a34488d2e7..a35e8c4d52 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -26,6 +26,7 @@ import static java.util.Arrays.copyOfRange; import static org.junit.Assert.assertArrayEquals; import static org.mockito.Mockito.when; +import android.os.Looper; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -40,6 +41,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; import java.util.Arrays; @@ -143,7 +145,10 @@ public final class SampleQueueTest { mockDrmSession = (DrmSession) Mockito.mock(DrmSession.class); when(mockDrmSessionManager.acquireSession(ArgumentMatchers.any(), ArgumentMatchers.any())) .thenReturn(mockDrmSession); - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager); + sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager); formatHolder = new FormatHolder(); inputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); } @@ -360,7 +365,10 @@ public final class SampleQueueTest { public void testIsReadyReturnsTrueForClearSampleAndPlayClearSamplesWithoutKeysIsTrue() { when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true); // We recreate the queue to ensure the mock DRM session manager flags are taken into account. - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager); + sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager); writeTestDataWithEncryptedSections(); assertThat(sampleQueue.isReady(/* loadingFinished= */ false)).isTrue(); } @@ -542,7 +550,10 @@ public final class SampleQueueTest { public void testAllowPlayClearSamplesWithoutKeysReadsClearSamples() { when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true); // We recreate the queue to ensure the mock DRM session manager flags are taken into account. - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager); + sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager); when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED); writeTestDataWithEncryptedSections(); @@ -931,7 +942,10 @@ public final class SampleQueueTest { public void testAdjustUpstreamFormat() { String label = "label"; sampleQueue = - new SampleQueue(allocator, mockDrmSessionManager) { + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager) { @Override public Format getAdjustedUpstreamFormat(Format format) { return super.getAdjustedUpstreamFormat(format.copyWithLabel(label)); @@ -947,7 +961,10 @@ public final class SampleQueueTest { public void testInvalidateUpstreamFormatAdjustment() { AtomicReference label = new AtomicReference<>("label1"); sampleQueue = - new SampleQueue(allocator, mockDrmSessionManager) { + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager) { @Override public Format getAdjustedUpstreamFormat(Format format) { return super.getAdjustedUpstreamFormat(format.copyWithLabel(label.get())); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index 3b52e070a6..187baad76b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -284,7 +284,10 @@ public final class PlayerEmsgHandler implements Handler.Callback { private final MetadataInputBuffer buffer; /* package */ PlayerTrackEmsgHandler(Allocator allocator) { - this.sampleQueue = new SampleQueue(allocator, DrmSessionManager.getDummyDrmSessionManager()); + this.sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ handler.getLooper(), + DrmSessionManager.getDummyDrmSessionManager()); formatHolder = new FormatHolder(); buffer = new MetadataInputBuffer(); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 03a67a1407..c7116ba878 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.hls; import android.net.Uri; import android.os.Handler; +import android.os.Looper; import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -907,7 +908,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; FormatAdjustingSampleQueue trackOutput = - new FormatAdjustingSampleQueue(allocator, drmSessionManager, overridingDrmInitData); + new FormatAdjustingSampleQueue( + allocator, + /* playbackLooper= */ handler.getLooper(), + drmSessionManager, + overridingDrmInitData); if (isAudioVideo) { trackOutput.setDrmInitData(drmInitData); } @@ -1331,9 +1336,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public FormatAdjustingSampleQueue( Allocator allocator, + Looper playbackLooper, DrmSessionManager drmSessionManager, Map overridingDrmInitData) { - super(allocator, drmSessionManager); + super(allocator, playbackLooper, drmSessionManager); this.overridingDrmInitData = overridingDrmInitData; } From f052e89a8ff9edeeac816200d27c24e6a87b9100 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 27 Apr 2020 10:27:31 +0100 Subject: [PATCH 0325/1052] Pass app context to the IMA SDK Notes: this doesn't fix the current issue where the component containing the ad overlay view leaks, but is good practice anyway. PiperOrigin-RevId: 308582036 --- .../com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 98dbef7c6c..a37294365c 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -477,7 +477,9 @@ public final class ImaAdsLoader adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); - adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); + adsLoader = + imaFactory.createAdsLoader( + context.getApplicationContext(), imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(/* adErrorListener= */ this); adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; From 8760424d76bcb94efa0d7e39197c3758a161e7ea Mon Sep 17 00:00:00 2001 From: vigneshv Date: Mon, 27 Apr 2020 21:15:49 +0100 Subject: [PATCH 0326/1052] av1_extension: Add a heuristic to determine default thread count Android scheduler has performance issues when a device has a combiation of big/medium/little cores. Add a heuristic to set the default number of threads used for deocding to the number of "performance" (i.e. big) cores. PiperOrigin-RevId: 308683989 --- RELEASENOTES.md | 2 + .../exoplayer2/ext/av1/Gav1Decoder.java | 23 ++- .../ext/av1/Libgav1VideoRenderer.java | 15 +- extensions/av1/src/main/jni/CMakeLists.txt | 4 +- extensions/av1/src/main/jni/cpu_info.cc | 153 ++++++++++++++++++ extensions/av1/src/main/jni/cpu_info.h | 13 ++ extensions/av1/src/main/jni/gav1_jni.cc | 5 + 7 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 extensions/av1/src/main/jni/cpu_info.cc create mode 100644 extensions/av1/src/main/jni/cpu_info.h diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 89d630527a..95e2a0581f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -20,6 +20,8 @@ * Text * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles ([#6950](https://github.com/google/ExoPlayer/pull/6950)). +* AV1 extension: Add a heuristic to determine the default number of threads + used for AV1 playback using the extension. ### 2.11.4 (2020-04-08) ### diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java index 687ac47f2a..cdff8581f1 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.av1; +import static java.lang.Runtime.getRuntime; + import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -44,7 +46,9 @@ import java.nio.ByteBuffer; * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. * @param initialInputBufferSize The initial size of each input buffer, in bytes. - * @param threads Number of threads libgav1 will use to decode. + * @param threads Number of threads libgav1 will use to decode. If {@link + * Libgav1VideoRenderer#THREAD_COUNT_AUTODETECT} is passed, then this class will auto detect + * the number of threads to be used. * @throws Gav1DecoderException Thrown if an exception occurs when initializing the decoder. */ public Gav1Decoder( @@ -56,6 +60,16 @@ import java.nio.ByteBuffer; if (!Gav1Library.isAvailable()) { throw new Gav1DecoderException("Failed to load decoder native library."); } + + if (threads == Libgav1VideoRenderer.THREAD_COUNT_AUTODETECT) { + // Try to get the optimal number of threads from the AV1 heuristic. + threads = gav1GetThreads(); + if (threads <= 0) { + // If that is not available, default to the number of available processors. + threads = getRuntime().availableProcessors(); + } + } + gav1DecoderContext = gav1Init(threads); if (gav1DecoderContext == GAV1_ERROR || gav1CheckError(gav1DecoderContext) == GAV1_ERROR) { throw new Gav1DecoderException( @@ -231,4 +245,11 @@ import java.nio.ByteBuffer; * @return {@link #GAV1_OK} if there was no error, {@link #GAV1_ERROR} if an error occured. */ private native int gav1CheckError(long context); + + /** + * Returns the optimal number of threads to be used for AV1 decoding. + * + * @return Optimal number of threads if there was no error, 0 if an error occurred. + */ + private native int gav1GetThreads(); } diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java index 3d10c2579b..122a94b7b1 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.ext.av1; -import static java.lang.Runtime.getRuntime; - import android.os.Handler; import android.view.Surface; import androidx.annotation.Nullable; @@ -55,6 +53,13 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; */ public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer { + /** + * Attempts to use as many threads as performance processors available on the device. If the + * number of performance processors cannot be detected, the number of available processors is + * used. + */ + public static final int THREAD_COUNT_AUTODETECT = 0; + private static final int DEFAULT_NUM_OF_INPUT_BUFFERS = 4; private static final int DEFAULT_NUM_OF_OUTPUT_BUFFERS = 4; /* Default size based on 720p resolution video compressed by a factor of two. */ @@ -94,7 +99,7 @@ public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer { eventHandler, eventListener, maxDroppedFramesToNotify, - /* threads= */ getRuntime().availableProcessors(), + THREAD_COUNT_AUTODETECT, DEFAULT_NUM_OF_INPUT_BUFFERS, DEFAULT_NUM_OF_OUTPUT_BUFFERS); } @@ -109,7 +114,9 @@ public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer { * @param eventListener A listener of events. May be null if delivery of events is not required. * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. - * @param threads Number of threads libgav1 will use to decode. + * @param threads Number of threads libgav1 will use to decode. If + * {@link #THREAD_COUNT_AUTODETECT} is passed, then the number of threads to use is + * auto-detected based on CPU capabilities. * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. */ diff --git a/extensions/av1/src/main/jni/CMakeLists.txt b/extensions/av1/src/main/jni/CMakeLists.txt index c7989d4ef2..075773a70e 100644 --- a/extensions/av1/src/main/jni/CMakeLists.txt +++ b/extensions/av1/src/main/jni/CMakeLists.txt @@ -44,7 +44,9 @@ add_subdirectory("${libgav1_root}" # Build libgav1JNI. add_library(gav1JNI SHARED - gav1_jni.cc) + gav1_jni.cc + cpu_info.cc + cpu_info.h) # Locate NDK log library. find_library(android_log_lib log) diff --git a/extensions/av1/src/main/jni/cpu_info.cc b/extensions/av1/src/main/jni/cpu_info.cc new file mode 100644 index 0000000000..8f4a405f4f --- /dev/null +++ b/extensions/av1/src/main/jni/cpu_info.cc @@ -0,0 +1,153 @@ +#include "cpu_info.h" // NOLINT + +#include + +#include +#include +#include +#include +#include + +namespace gav1_jni { +namespace { + +// Note: The code in this file needs to use the 'long' type because it is the +// return type of the Standard C Library function strtol(). The linter warnings +// are suppressed with NOLINT comments since they are integers at runtime. + +// Returns the number of online processor cores. +int GetNumberOfProcessorsOnline() { + // See https://developer.android.com/ndk/guides/cpu-features. + long num_cpus = sysconf(_SC_NPROCESSORS_ONLN); // NOLINT + if (num_cpus < 0) { + return 0; + } + // It is safe to cast num_cpus to int. sysconf(_SC_NPROCESSORS_ONLN) returns + // the return value of get_nprocs(), which is an int. + return static_cast(num_cpus); +} + +} // namespace + +// These CPUs support heterogeneous multiprocessing. +#if defined(__arm__) || defined(__aarch64__) + +// A helper function used by GetNumberOfPerformanceCoresOnline(). +// +// Returns the cpuinfo_max_freq value (in kHz) of the given CPU. Returns 0 on +// failure. +long GetCpuinfoMaxFreq(int cpu_index) { // NOLINT + char buffer[128]; + const int rv = snprintf( + buffer, sizeof(buffer), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", cpu_index); + if (rv < 0 || rv >= sizeof(buffer)) { + return 0; + } + FILE* file = fopen(buffer, "r"); + if (file == nullptr) { + return 0; + } + char* const str = fgets(buffer, sizeof(buffer), file); + fclose(file); + if (str == nullptr) { + return 0; + } + const long freq = strtol(str, nullptr, 10); // NOLINT + if (freq <= 0 || freq == LONG_MAX) { + return 0; + } + return freq; +} + +// Returns the number of performance CPU cores that are online. The number of +// efficiency CPU cores is subtracted from the total number of CPU cores. Uses +// cpuinfo_max_freq to determine whether a CPU is a performance core or an +// efficiency core. +// +// This function is not perfect. For example, the Snapdragon 632 SoC used in +// Motorola Moto G7 has performance and efficiency cores with the same +// cpuinfo_max_freq but different cpuinfo_min_freq. This function fails to +// differentiate the two kinds of cores and reports all the cores as +// performance cores. +int GetNumberOfPerformanceCoresOnline() { + // Get the online CPU list. Some examples of the online CPU list are: + // "0-7" + // "0" + // "0-1,2,3,4-7" + FILE* file = fopen("/sys/devices/system/cpu/online", "r"); + if (file == nullptr) { + return 0; + } + char online[512]; + char* const str = fgets(online, sizeof(online), file); + fclose(file); + file = nullptr; + if (str == nullptr) { + return 0; + } + + // Count the number of the slowest CPUs. Some SoCs such as Snapdragon 855 + // have performance cores with different max frequencies, so only the slowest + // CPUs are efficiency cores. If we count the number of the fastest CPUs, we + // will fail to count the second fastest performance cores. + long slowest_cpu_freq = LONG_MAX; // NOLINT + int num_slowest_cpus = 0; + int num_cpus = 0; + const char* cp = online; + int range_begin = -1; + while (true) { + char* str_end; + const int cpu = static_cast(strtol(cp, &str_end, 10)); // NOLINT + if (str_end == cp) { + break; + } + cp = str_end; + if (*cp == '-') { + range_begin = cpu; + } else { + if (range_begin == -1) { + range_begin = cpu; + } + + num_cpus += cpu - range_begin + 1; + for (int i = range_begin; i <= cpu; ++i) { + const long freq = GetCpuinfoMaxFreq(i); // NOLINT + if (freq <= 0) { + return 0; + } + if (freq < slowest_cpu_freq) { + slowest_cpu_freq = freq; + num_slowest_cpus = 0; + } + if (freq == slowest_cpu_freq) { + ++num_slowest_cpus; + } + } + + range_begin = -1; + } + if (*cp == '\0') { + break; + } + ++cp; + } + + // If there are faster CPU cores than the slowest CPU cores, exclude the + // slowest CPU cores. + if (num_slowest_cpus < num_cpus) { + num_cpus -= num_slowest_cpus; + } + return num_cpus; +} + +#else + +// Assume symmetric multiprocessing. +int GetNumberOfPerformanceCoresOnline() { + return GetNumberOfProcessorsOnline(); +} + +#endif + +} // namespace gav1_jni diff --git a/extensions/av1/src/main/jni/cpu_info.h b/extensions/av1/src/main/jni/cpu_info.h new file mode 100644 index 0000000000..77f869a93e --- /dev/null +++ b/extensions/av1/src/main/jni/cpu_info.h @@ -0,0 +1,13 @@ +#ifndef EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ +#define EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ + +namespace gav1_jni { + +// Returns the number of performance cores that are available for AV1 decoding. +// This is a heuristic that works on most common android devices. Returns 0 on +// error or if the number of performance cores cannot be determined. +int GetNumberOfPerformanceCoresOnline(); + +} // namespace gav1_jni + +#endif // EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index e0cef86d22..714ab499b1 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -32,6 +32,7 @@ #include // NOLINT #include +#include "cpu_info.h" // NOLINT #include "gav1/decoder.h" #define LOG_TAG "gav1_jni" @@ -774,5 +775,9 @@ DECODER_FUNC(jint, gav1CheckError, jlong jContext) { return kStatusOk; } +DECODER_FUNC(jint, gav1GetThreads) { + return gav1_jni::GetNumberOfPerformanceCoresOnline(); +} + // TODO(b/139902005): Add functions for getting libgav1 version and build // configuration once libgav1 ABI provides this information. From 3ac4c1a6e57766dc74d6f3e266eb008605a6331c Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 15 Apr 2020 09:52:51 +0100 Subject: [PATCH 0327/1052] Add Clock#currentTimeMillis() PiperOrigin-RevId: 306602043 --- .../google/android/exoplayer2/util/Clock.java | 7 +++ .../android/exoplayer2/util/SystemClock.java | 5 +++ .../exoplayer2/testutil/FakeClock.java | 45 ++++++++++++++----- .../exoplayer2/testutil/FakeClockTest.java | 19 ++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java index 7a87d7d9a3..ffb8236bd1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java @@ -30,6 +30,13 @@ public interface Clock { */ Clock DEFAULT = new SystemClock(); + /** + * Returns the current time in milliseconds since the Unix Epoch. + * + * @see System#currentTimeMillis() + */ + long currentTimeMillis(); + /** @see android.os.SystemClock#elapsedRealtime() */ long elapsedRealtime(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java index be526595c6..a094e810bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java @@ -25,6 +25,11 @@ import androidx.annotation.Nullable; */ /* package */ final class SystemClock implements Clock { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + @Override public long elapsedRealtime() { return android.os.SystemClock.elapsedRealtime(); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java index a591546613..dcf454449c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.testutil; import android.os.Handler.Callback; import android.os.Looper; import android.os.Message; +import androidx.annotation.GuardedBy; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.HandlerWrapper; import java.util.ArrayList; @@ -28,16 +29,31 @@ public class FakeClock implements Clock { private final List wakeUpTimes; private final List handlerMessages; + private final long bootTimeMs; - private long currentTimeMs; + @GuardedBy("this") + private long timeSinceBootMs; /** - * Create {@link FakeClock} with an arbitrary initial timestamp. + * Creates a fake clock assuming the system was booted exactly at time {@code 0} (the Unix Epoch) + * and {@code initialTimeMs} milliseconds have passed since system boot. * - * @param initialTimeMs Initial timestamp in milliseconds. + * @param initialTimeMs The initial elapsed time since the boot time, in milliseconds. */ public FakeClock(long initialTimeMs) { - this.currentTimeMs = initialTimeMs; + this(/* bootTimeMs= */ 0, initialTimeMs); + } + + /** + * Creates a fake clock specifying when the system was booted and how much time has passed since + * then. + * + * @param bootTimeMs The time the system was booted since the Unix Epoch, in milliseconds. + * @param initialTimeMs The initial elapsed time since the boot time, in milliseconds. + */ + public FakeClock(long bootTimeMs, long initialTimeMs) { + this.bootTimeMs = bootTimeMs; + this.timeSinceBootMs = initialTimeMs; this.wakeUpTimes = new ArrayList<>(); this.handlerMessages = new ArrayList<>(); } @@ -48,23 +64,28 @@ public class FakeClock implements Clock { * @param timeDiffMs The amount of time to add to the timestamp in milliseconds. */ public synchronized void advanceTime(long timeDiffMs) { - currentTimeMs += timeDiffMs; + timeSinceBootMs += timeDiffMs; for (Long wakeUpTime : wakeUpTimes) { - if (wakeUpTime <= currentTimeMs) { + if (wakeUpTime <= timeSinceBootMs) { notifyAll(); break; } } for (int i = handlerMessages.size() - 1; i >= 0; i--) { - if (handlerMessages.get(i).maybeSendToTarget(currentTimeMs)) { + if (handlerMessages.get(i).maybeSendToTarget(timeSinceBootMs)) { handlerMessages.remove(i); } } } + @Override + public synchronized long currentTimeMillis() { + return bootTimeMs + timeSinceBootMs; + } + @Override public synchronized long elapsedRealtime() { - return currentTimeMs; + return timeSinceBootMs; } @Override @@ -77,9 +98,9 @@ public class FakeClock implements Clock { if (sleepTimeMs <= 0) { return; } - Long wakeUpTimeMs = currentTimeMs + sleepTimeMs; + Long wakeUpTimeMs = timeSinceBootMs + sleepTimeMs; wakeUpTimes.add(wakeUpTimeMs); - while (currentTimeMs < wakeUpTimeMs) { + while (timeSinceBootMs < wakeUpTimeMs) { try { wait(); } catch (InterruptedException e) { @@ -97,7 +118,7 @@ public class FakeClock implements Clock { /** Adds a handler post to list of pending messages. */ protected synchronized boolean addHandlerMessageAtTime( HandlerWrapper handler, Runnable runnable, long timeMs) { - if (timeMs <= currentTimeMs) { + if (timeMs <= timeSinceBootMs) { return handler.post(runnable); } handlerMessages.add(new HandlerMessageData(timeMs, handler, runnable)); @@ -107,7 +128,7 @@ public class FakeClock implements Clock { /** Adds an empty handler message to list of pending messages. */ protected synchronized boolean addHandlerMessageAtTime( HandlerWrapper handler, int message, long timeMs) { - if (timeMs <= currentTimeMs) { + if (timeMs <= timeSinceBootMs) { return handler.sendEmptyMessage(message); } handlerMessages.add(new HandlerMessageData(timeMs, handler, message)); diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java index c82980d7a4..55e0d29f01 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java @@ -35,6 +35,25 @@ public final class FakeClockTest { private static final long TIMEOUT_MS = 10000; + @Test + public void currentTimeMillis_withoutBootTime() { + FakeClock fakeClock = new FakeClock(/* initialTimeMs= */ 10); + assertThat(fakeClock.currentTimeMillis()).isEqualTo(10); + } + + @Test + public void currentTimeMillis_withBootTime() { + FakeClock fakeClock = new FakeClock(/* bootTimeMs= */ 150, /* initialTimeMs= */ 200); + assertThat(fakeClock.currentTimeMillis()).isEqualTo(350); + } + + @Test + public void currentTimeMillis_advanceTime_currentTimeHasAdvanced() { + FakeClock fakeClock = new FakeClock(/* bootTimeMs= */ 100, /* initialTimeMs= */ 50); + fakeClock.advanceTime(/* timeDiffMs */ 250); + assertThat(fakeClock.currentTimeMillis()).isEqualTo(400); + } + @Test public void testAdvanceTime() { FakeClock fakeClock = new FakeClock(2000); From 9213ffafa8bb72abed9ff8887132a610639a2303 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 28 Apr 2020 14:35:49 +0100 Subject: [PATCH 0328/1052] ConditionVariable: Improve documentation and allow clock injection - Improve documentation explaining the benefits of ExoPlayer's ConditionVariable over the one that the platform provides - Allow Clock injection - Create TestUtil method for obtaining a ConditionVariable whose block(long) method times out correctly when used in a Robolectric test - Add basic unit tests for ConditionVariable PiperOrigin-RevId: 308812698 --- .../exoplayer2/util/ConditionVariable.java | 34 ++++- .../android/exoplayer2/util/SystemClock.java | 7 +- .../util/ConditionVariableTest.java | 118 ++++++++++++++++++ .../android/exoplayer2/testutil/TestUtil.java | 21 ++++ .../exoplayer2/testutil/TestUtilTest.java | 46 +++++++ 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java create mode 100644 testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java index c035c62a7e..1b5cd47401 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java @@ -16,13 +16,39 @@ package com.google.android.exoplayer2.util; /** - * An interruptible condition variable whose {@link #open()} and {@link #close()} methods return - * whether they resulted in a change of state. + * An interruptible condition variable. This class provides a number of benefits over {@link + * android.os.ConditionVariable}: + * + *
      + *
    • Consistent use of ({@link Clock#elapsedRealtime()} for timing {@link #block(long)} timeout + * intervals. {@link android.os.ConditionVariable} used {@link System#currentTimeMillis()} + * prior to Android 10, which is not a correct clock to use for interval timing because it's + * not guaranteed to be monotonic. + *
    • Support for injecting a custom {@link Clock}. + *
    • The ability to query the variable's current state, by calling {@link #isOpen()}. + *
    • {@link #open()} and {@link #close()} return whether they changed the variable's state. + *
    */ public final class ConditionVariable { + private final Clock clock; private boolean isOpen; + /** Creates an instance using {@link Clock#DEFAULT}. */ + public ConditionVariable() { + this(Clock.DEFAULT); + } + + /** + * Creates an instance. + * + * @param clock The {@link Clock} whose {@link Clock#elapsedRealtime()} method is used to + * determine when {@link #block(long)} should time out. + */ + public ConditionVariable(Clock clock) { + this.clock = clock; + } + /** * Opens the condition and releases all threads that are blocked. * @@ -67,11 +93,11 @@ public final class ConditionVariable { * @throws InterruptedException If the thread is interrupted. */ public synchronized boolean block(long timeout) throws InterruptedException { - long now = android.os.SystemClock.elapsedRealtime(); + long now = clock.elapsedRealtime(); long end = now + timeout; while (!isOpen && now < end) { wait(end - now); - now = android.os.SystemClock.elapsedRealtime(); + now = clock.elapsedRealtime(); } return isOpen; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java index a094e810bf..89e1c60d7a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java @@ -21,9 +21,12 @@ import android.os.Looper; import androidx.annotation.Nullable; /** - * The standard implementation of {@link Clock}. + * The standard implementation of {@link Clock}, an instance of which is available via {@link + * SystemClock#DEFAULT}. */ -/* package */ final class SystemClock implements Clock { +public class SystemClock implements Clock { + + protected SystemClock() {} @Override public long currentTimeMillis() { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java new file mode 100644 index 0000000000..1e47aa680d --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ConditionVariableTest}. */ +@RunWith(AndroidJUnit4.class) +public class ConditionVariableTest { + + @Test + public void initialState_isClosed() { + ConditionVariable conditionVariable = buildTestConditionVariable(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithTimeout_timesOut() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + assertThat(conditionVariable.block(1)).isFalse(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithTimeout_blocksForAtLeastTimeout() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + long startTimeMs = System.currentTimeMillis(); + assertThat(conditionVariable.block(/* timeout= */ 500)).isFalse(); + long endTimeMs = System.currentTimeMillis(); + assertThat(endTimeMs - startTimeMs).isAtLeast(500L); + } + + @Test + public void blockWithoutTimeout_blocks() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void open_unblocksBlock() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + conditionVariable.open(); + blockingThread.join(); + assertThat(blockReturned.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isTrue(); + } + + private static ConditionVariable buildTestConditionVariable() { + return new ConditionVariable( + new SystemClock() { + @Override + public long elapsedRealtime() { + // elapsedRealtime() does not advance during Robolectric test execution, so use + // currentTimeMillis() instead. This is technically unsafe because this clock is not + // guaranteed to be monotonic, but in practice it will work provided the clock of the + // host machine does not change during test execution. + return Clock.DEFAULT.currentTimeMillis(); + } + }); + } +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index c47b438100..b0beb1ba13 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -36,6 +36,9 @@ import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.SystemClock; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.io.InputStream; @@ -441,4 +444,22 @@ public class TestUtil { } return new DefaultExtractorInput(dataSource, position, length); } + + /** + * Creates a {@link ConditionVariable} whose {@link ConditionVariable#block(long)} method times + * out according to wallclock time when used in Robolectric tests. + */ + public static ConditionVariable createRobolectricConditionVariable() { + return new ConditionVariable( + new SystemClock() { + @Override + public long elapsedRealtime() { + // elapsedRealtime() does not advance during Robolectric test execution, so use + // currentTimeMillis() instead. This is technically unsafe because this clock is not + // guaranteed to be monotonic, but in practice it will work provided the clock of the + // host machine does not change during test execution. + return Clock.DEFAULT.currentTimeMillis(); + } + }); + } } diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java new file mode 100644 index 0000000000..a80d474f9b --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.ConditionVariable; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link TestUtil}. */ +@RunWith(AndroidJUnit4.class) +public class TestUtilTest { + + @Test + public void createRobolectricConditionVariable_blockWithTimeout_timesOut() + throws InterruptedException { + ConditionVariable conditionVariable = TestUtil.createRobolectricConditionVariable(); + assertThat(conditionVariable.block(/* timeout= */ 1)).isFalse(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void createRobolectricConditionVariable_blockWithTimeout_blocksForAtLeastTimeout() + throws InterruptedException { + ConditionVariable conditionVariable = TestUtil.createRobolectricConditionVariable(); + long startTimeMs = System.currentTimeMillis(); + assertThat(conditionVariable.block(/* timeout= */ 500)).isFalse(); + long endTimeMs = System.currentTimeMillis(); + assertThat(endTimeMs - startTimeMs).isAtLeast(500); + } +} From 1772b0d9177880f458b48998df2047132dddd055 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 28 Apr 2020 14:59:23 +0100 Subject: [PATCH 0329/1052] ConditionVariable: Fix block(long) to correctly handle large timeouts PiperOrigin-RevId: 308815613 --- .../exoplayer2/util/ConditionVariable.java | 25 +++++++++++----- .../util/ConditionVariableTest.java | 29 ++++++++++++++++++- .../exoplayer2/testutil/TestUtilTest.java | 4 +-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java index 1b5cd47401..69782ab1e8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java @@ -86,18 +86,27 @@ public final class ConditionVariable { } /** - * Blocks until the condition is opened or until {@code timeout} milliseconds have passed. + * Blocks until the condition is opened or until {@code timeoutMs} have passed. * - * @param timeout The maximum time to wait in milliseconds. + * @param timeoutMs The maximum time to wait in milliseconds. If {@code timeoutMs <= 0} then the + * call will return immediately without blocking. * @return True if the condition was opened, false if the call returns because of the timeout. * @throws InterruptedException If the thread is interrupted. */ - public synchronized boolean block(long timeout) throws InterruptedException { - long now = clock.elapsedRealtime(); - long end = now + timeout; - while (!isOpen && now < end) { - wait(end - now); - now = clock.elapsedRealtime(); + public synchronized boolean block(long timeoutMs) throws InterruptedException { + if (timeoutMs <= 0) { + return isOpen; + } + long nowMs = clock.elapsedRealtime(); + long endMs = nowMs + timeoutMs; + if (endMs < nowMs) { + // timeoutMs is large enough for (nowMs + timeoutMs) to rollover. Block indefinitely. + block(); + } else { + while (!isOpen && nowMs < endMs) { + wait(endMs - nowMs); + nowMs = clock.elapsedRealtime(); + } } return isOpen; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java index 1e47aa680d..79eac5d1ad 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java @@ -43,7 +43,7 @@ public class ConditionVariableTest { public void blockWithTimeout_blocksForAtLeastTimeout() throws InterruptedException { ConditionVariable conditionVariable = buildTestConditionVariable(); long startTimeMs = System.currentTimeMillis(); - assertThat(conditionVariable.block(/* timeout= */ 500)).isFalse(); + assertThat(conditionVariable.block(/* timeoutMs= */ 500)).isFalse(); long endTimeMs = System.currentTimeMillis(); assertThat(endTimeMs - startTimeMs).isAtLeast(500L); } @@ -75,6 +75,33 @@ public class ConditionVariableTest { assertThat(conditionVariable.isOpen()).isFalse(); } + @Test + public void blockWithMaxTimeout_blocks() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(/* timeoutMs= */ Long.MAX_VALUE); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + @Test public void open_unblocksBlock() throws InterruptedException { ConditionVariable conditionVariable = buildTestConditionVariable(); diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java index a80d474f9b..0a999c4161 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java @@ -30,7 +30,7 @@ public class TestUtilTest { public void createRobolectricConditionVariable_blockWithTimeout_timesOut() throws InterruptedException { ConditionVariable conditionVariable = TestUtil.createRobolectricConditionVariable(); - assertThat(conditionVariable.block(/* timeout= */ 1)).isFalse(); + assertThat(conditionVariable.block(/* timeoutMs= */ 1)).isFalse(); assertThat(conditionVariable.isOpen()).isFalse(); } @@ -39,7 +39,7 @@ public class TestUtilTest { throws InterruptedException { ConditionVariable conditionVariable = TestUtil.createRobolectricConditionVariable(); long startTimeMs = System.currentTimeMillis(); - assertThat(conditionVariable.block(/* timeout= */ 500)).isFalse(); + assertThat(conditionVariable.block(/* timeoutMs= */ 500)).isFalse(); long endTimeMs = System.currentTimeMillis(); assertThat(endTimeMs - startTimeMs).isAtLeast(500); } From 2af9b4b06652f4c9af6e296e6c175e13cd0e0faa Mon Sep 17 00:00:00 2001 From: Joris de Groot Date: Thu, 28 May 2020 14:55:02 +0200 Subject: [PATCH 0330/1052] Updated documentation --- .../google/android/exoplayer2/scheduler/Requirements.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index ab364a53b8..503e28b578 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -39,7 +39,11 @@ public final class Requirements implements Parcelable { /** * Requirement flags. Possible flag values are {@link #NETWORK}, {@link #NETWORK_UNMETERED}, - * {@link #DEVICE_IDLE} and {@link #DEVICE_CHARGING}. + * {@link #DEVICE_IDLE}, {@link #DEVICE_CHARGING} and {@link #DEVICE_STORAGE_NOT_LOW}. + * + * Note that {@link #DEVICE_STORAGE_NOT_LOW} only works when downloading to internal storage. + * Setting this requirement when downloading to external storage will actually monitor internal + * storage and can lead to unexpected results. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -56,7 +60,7 @@ public final class Requirements implements Parcelable { public static final int DEVICE_IDLE = 1 << 2; /** Requirement that the device is charging. */ public static final int DEVICE_CHARGING = 1 << 3; - /** Requirement that the storage is not low. */ + /** Requirement that the device internal storage is not low. */ public static final int DEVICE_STORAGE_NOT_LOW = 1 << 4; @RequirementFlags private final int requirements; From a01fd007cb64498c190d84ae58beb5bb88d079f8 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 28 May 2020 00:02:35 +0100 Subject: [PATCH 0331/1052] Change setOutputSurfaceV23 visibility. PiperOrigin-RevId: 313481722 --- .../android/exoplayer2/video/MediaCodecVideoRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 9dc0c7230d..60e376cb76 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1244,7 +1244,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } @RequiresApi(23) - private static void setOutputSurfaceV23(MediaCodec codec, Surface surface) { + protected void setOutputSurfaceV23(MediaCodec codec, Surface surface) { codec.setOutputSurface(surface); } From 4419a26bbb6d8cdf4cda6998606831f90044e21e Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 28 May 2020 11:10:35 +0100 Subject: [PATCH 0332/1052] Use assertThrows in CacheWriterTest. PiperOrigin-RevId: 313556143 --- .../upstream/cache/CacheWriterTest.java | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java index 0e97756552..3e5cb119fd 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java @@ -17,7 +17,7 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData; import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; +import static org.junit.Assert.assertThrows; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; @@ -254,20 +254,19 @@ public final class CacheWriterTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); - try { - CacheWriter cacheWriter = - new CacheWriter( - new CacheDataSource(cache, dataSource), - dataSpec, - /* allowShortContent= */ false, - /* isCanceled= */ null, - /* temporaryBuffer= */ null, - /* progressListener= */ null); - cacheWriter.cache(); - fail(); - } catch (IOException e) { - assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue(); - } + IOException exception = + assertThrows( + IOException.class, + () -> + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* isCanceled= */ null, + /* temporaryBuffer= */ null, + /* progressListener= */ null) + .cache()); + assertThat(DataSourceException.isCausedByPositionOutOfRange(exception)).isTrue(); } @Test From a37374d5a7e13a5e6fc380e1a458a38ccd5dd8bd Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 28 May 2020 13:01:27 +0100 Subject: [PATCH 0333/1052] Initial (and incomplete) 2.11.5 release notes PiperOrigin-RevId: 313566474 --- RELEASENOTES.md | 80 +++++++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 317852ef21..98e5847ba5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -81,18 +81,10 @@ * Remove deprecated members in `DefaultTrackSelector`. * Add `Player.DeviceComponent` and implement it for `SimpleExoPlayer` so that the device volume can be controlled by player. - * Avoid throwing an exception while parsing fragmented MP4 default sample - values where the most-significant bit is set - ([#7207](https://github.com/google/ExoPlayer/issues/7207)). - * Add `SilenceMediaSource.Factory` to support tags - ([PR #7245](https://github.com/google/ExoPlayer/pull/7245)). - * Fix `AdsMediaSource` child `MediaSource`s not being released. * Parse track titles from Matroska files ([#7247](https://github.com/google/ExoPlayer/pull/7247)). * Replace `CacheDataSinkFactory` and `CacheDataSourceFactory` with `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively. - * Enable the configuration of `SilenceSkippingAudioProcessor` - ([#6705](https://github.com/google/ExoPlayer/issues/6705)). * Extend `EventTime` with more details about the current player state for easier access ([#7332](https://github.com/google/ExoPlayer/issues/7332)). @@ -104,8 +96,6 @@ subtitles (rendering is coming later). * Parse `tts:combineText` property (i.e. tate-chu-yoko) in TTML subtitles (rendering is coming later). - * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct - color ([#6724](https://github.com/google/ExoPlayer/pull/6724)). * Add support for WebVTT default [text](https://www.w3.org/TR/webvtt1/#default-text-color) and [background](https://www.w3.org/TR/webvtt1/#default-text-background) @@ -119,8 +109,6 @@ (a [previous draft](https://www.w3.org/TR/2014/WD-webvtt1-20141111/#dfn-webvtt-text-position-cue-setting) used `start`, `middle` and `end`). - * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles - ([#6950](https://github.com/google/ExoPlayer/pull/6950)). * Implement timing-out of stuck CEA-608 captions (as permitted by ANSI/CTA-608-E R-2014 Annex C.9) and set the default timeout to 16 seconds ([#7181](https://github.com/google/ExoPlayer/issues/7181)). @@ -161,13 +149,7 @@ the `AudioCapabilities` ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * DASH: - * Merge trick play adaptation sets (i.e., adaptation sets marked with - `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as - the main adaptation sets to which they refer. Trick play tracks are - marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * Enable support for embedded CEA-708. - * Fix assertion failure in `SampleQueue` when playing DASH streams with - EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). * HLS: * Add support for upstream discard including cancelation of ongoing load ([#6322](https://github.com/google/ExoPlayer/issues/6322)). @@ -177,12 +159,6 @@ is enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the `Mp3Extractor`. It may require to scan a significant portion of the file for seeking, which may be costly on large files. - * Allow MP3 files with XING headers that are larger than 2GB to be played - ([#7337](https://github.com/google/ExoPlayer/issues/7337)). -* MP4: Store the Android capture frame rate only in `Format.metadata`. - `Format.frameRate` now stores the calculated frame rate. -* MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 - samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). * Testing * Add `TestExoPlayer`, a utility class with APIs to create `SimpleExoPlayer` instances with fake components for testing. @@ -191,11 +167,8 @@ * UI * Remove deperecated `exo_simple_player_view.xml` and `exo_playback_control_view.xml` from resource. - * Add `showScrubber` and `hideScrubber` methods to DefaultTimeBar. * Move logic of prev, next, fast forward and rewind to ControlDispatcher ([#6926](https://github.com/google/ExoPlayer/issues/6926)). - * Update `TrackSelectionDialogBuilder` to use androidx compat Dialog - ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Metadata: Add minimal DVB Application Information Table (AIT) support ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue @@ -204,10 +177,6 @@ * Change the order of extractors for sniffing to reduce start-up latency in `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` ([#6410](https://github.com/google/ExoPlayer/issues/6410)). -* Add missing `@Nullable` annotations to `MediaSessionConnector` - ([#7234](https://github.com/google/ExoPlayer/issues/7234)). -* AV1 extension: Add a heuristic to determine the default number of threads - used for AV1 playback using the extension. * IMA extension: * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes @@ -227,15 +196,54 @@ ([#5444](https://github.com/google/ExoPlayer/issues/5444), [#5966](https://github.com/google/ExoPlayer/issues/5966), [#7002](https://github.com/google/ExoPlayer/issues/7002)). -* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* Add Guava dependency. + +### 2.11.5 (not yet released) ### + +* Add `SilenceMediaSource.Factory` to support tags. +* Enable the configuration of `SilenceSkippingAudioProcessor` + ([#6705](https://github.com/google/ExoPlayer/issues/6705)). +* Ads: + * Fix `AdsMediaSource` child `MediaSource`s not being released. +* DASH: + * Merge trick play adaptation sets (i.e., adaptation sets marked with + `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as + the main adaptation sets to which they refer. Trick play tracks are + marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. + * Fix assertion failure in `SampleQueue` when playing DASH streams with + EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). +* MP4: Store the Android capture frame rate only in `Format.metadata`. + `Format.frameRate` now stores the calculated frame rate. +* FMP4: Avoid throwing an exception while parsing default sample values whose + most significant bits are set + ([#7207](https://github.com/google/ExoPlayer/issues/7207)). +* MP3: Fix issue parsing the XING headers belonging to files larger than 2GB + ([#7337](https://github.com/google/ExoPlayer/issues/7337)). +* MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 + samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). +* UI: + * Fix `DefaultTimeBar` to respect touch transformations + ([#7303](https://github.com/google/ExoPlayer/issues/7303)). + * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. + * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog + ([#7357](https://github.com/google/ExoPlayer/issues/7357)). +* Text: + * Use anti-aliasing and bitmap filtering when displaying bitmap + subtitles. + * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct + color. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when embedding the library. -* MediaSession extension: Set session playback state to BUFFERING only when - actually playing ([#7367](https://github.com/google/ExoPlayer/pull/7367), - [#7206](https://github.com/google/ExoPlayer/issues/7206)). -* Add Guava dependency. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* MediaSession extension: + * Only set the playback state to `BUFFERING` if `playWhenReady` is true + ([#7206](https://github.com/google/ExoPlayer/issues/7206)). + * Add missing `@Nullable` annotations to `MediaSessionConnector` + ([#7234](https://github.com/google/ExoPlayer/issues/7234)). +* AV1 extension: Add a heuristic to determine the default number of threads + used for AV1 playback using the extension. ### 2.11.4 (2020-04-08) From a3b721e6802fa268c657d1c6493876ef2d3b146d Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 28 May 2020 14:11:10 +0100 Subject: [PATCH 0334/1052] Make SsMediaSource add the media item to the timeline PiperOrigin-RevId: 313573424 --- .../source/smoothstreaming/SsMediaSource.java | 95 +++++++++--- .../smoothstreaming/SsMediaSourceTest.java | 137 ++++++++++++++++++ 2 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 24e835ee90..f75be283e2 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.smoothstreaming; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import android.os.Handler; import android.os.SystemClock; @@ -54,6 +56,7 @@ import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -104,7 +107,7 @@ public final class SsMediaSource extends BaseMediaSource public Factory( SsChunkSource.Factory chunkSourceFactory, @Nullable DataSource.Factory manifestDataSourceFactory) { - this.chunkSourceFactory = Assertions.checkNotNull(chunkSourceFactory); + this.chunkSourceFactory = checkNotNull(chunkSourceFactory); this.manifestDataSourceFactory = manifestDataSourceFactory; drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); @@ -237,21 +240,47 @@ public final class SsMediaSource extends BaseMediaSource * @throws IllegalArgumentException If {@link SsManifest#isLive} is true. */ public SsMediaSource createMediaSource(SsManifest manifest) { + return createMediaSource(manifest, MediaItem.fromUri(Uri.EMPTY)); + } + + /** + * Returns a new {@link SsMediaSource} using the current parameters and the specified sideloaded + * manifest. + * + * @param manifest The manifest. {@link SsManifest#isLive} must be false. + * @param mediaItem The {@link MediaItem} to be included in the timeline. + * @return The new {@link SsMediaSource}. + * @throws IllegalArgumentException If {@link SsManifest#isLive} is true. + */ + public SsMediaSource createMediaSource(SsManifest manifest, MediaItem mediaItem) { Assertions.checkArgument(!manifest.isLive); + List streamKeys = + mediaItem.playbackProperties != null && !mediaItem.playbackProperties.streamKeys.isEmpty() + ? mediaItem.playbackProperties.streamKeys + : this.streamKeys; if (!streamKeys.isEmpty()) { manifest = manifest.copy(streamKeys); } + boolean hasUri = mediaItem.playbackProperties != null; + boolean hasTag = hasUri && mediaItem.playbackProperties.tag != null; + mediaItem = + mediaItem + .buildUpon() + .setMimeType(MimeTypes.APPLICATION_SS) + .setUri(hasUri ? mediaItem.playbackProperties.uri : Uri.EMPTY) + .setTag(hasTag ? mediaItem.playbackProperties.tag : tag) + .setStreamKeys(streamKeys) + .build(); return new SsMediaSource( + mediaItem, manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, compositeSequenceableLoaderFactory, drmSessionManager, loadErrorHandlingPolicy, - livePresentationDelayMs, - tag); + livePresentationDelayMs); } /** @@ -274,6 +303,7 @@ public final class SsMediaSource extends BaseMediaSource * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ + @SuppressWarnings("deprecation") @Deprecated public SsMediaSource createMediaSource( Uri manifestUri, @@ -295,7 +325,7 @@ public final class SsMediaSource extends BaseMediaSource */ @Override public SsMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); @Nullable ParsingLoadable.Parser manifestParser = this.manifestParser; if (manifestParser == null) { manifestParser = new SsManifestParser(); @@ -307,17 +337,27 @@ public final class SsMediaSource extends BaseMediaSource if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } + + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsStreamKeys = + mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty(); + if (needsTag && needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); + } return new SsMediaSource( + mediaItem, /* manifest= */ null, - mediaItem.playbackProperties.uri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, drmSessionManager, loadErrorHandlingPolicy, - livePresentationDelayMs, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + livePresentationDelayMs); } @Override @@ -343,6 +383,8 @@ public final class SsMediaSource extends BaseMediaSource private final boolean sideloadedManifest; private final Uri manifestUri; + private final MediaItem.PlaybackProperties playbackProperties; + private final MediaItem mediaItem; private final DataSource.Factory manifestDataSourceFactory; private final SsChunkSource.Factory chunkSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @@ -352,7 +394,6 @@ public final class SsMediaSource extends BaseMediaSource private final EventDispatcher manifestEventDispatcher; private final ParsingLoadable.Parser manifestParser; private final ArrayList mediaPeriods; - @Nullable private final Object tag; private DataSource manifestDataSource; private Loader manifestLoader; @@ -406,16 +447,15 @@ public final class SsMediaSource extends BaseMediaSource @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(Uri.EMPTY).setMimeType(MimeTypes.APPLICATION_SS).build(), manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, new DefaultCompositeSequenceableLoaderFactory(), DrmSessionManager.getDummyDrmSessionManager(), new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), - DEFAULT_LIVE_PRESENTATION_DELAY_MS, - /* tag= */ null); + DEFAULT_LIVE_PRESENTATION_DELAY_MS); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } @@ -507,35 +547,38 @@ public final class SsMediaSource extends BaseMediaSource @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(manifestUri).setMimeType(MimeTypes.APPLICATION_SS).build(), /* manifest= */ null, - manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, new DefaultCompositeSequenceableLoaderFactory(), DrmSessionManager.getDummyDrmSessionManager(), new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), - livePresentationDelayMs, - /* tag= */ null); + livePresentationDelayMs); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } } private SsMediaSource( + MediaItem mediaItem, @Nullable SsManifest manifest, - @Nullable Uri manifestUri, @Nullable DataSource.Factory manifestDataSourceFactory, @Nullable ParsingLoadable.Parser manifestParser, SsChunkSource.Factory chunkSourceFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - long livePresentationDelayMs, - @Nullable Object tag) { + long livePresentationDelayMs) { Assertions.checkState(manifest == null || !manifest.isLive); + this.mediaItem = mediaItem; + playbackProperties = checkNotNull(mediaItem.playbackProperties); this.manifest = manifest; - this.manifestUri = manifestUri == null ? null : SsUtil.fixManifestUri(manifestUri); + this.manifestUri = + playbackProperties.uri.equals(Uri.EMPTY) + ? null + : SsUtil.fixManifestUri(playbackProperties.uri); this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; @@ -544,7 +587,6 @@ public final class SsMediaSource extends BaseMediaSource this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.livePresentationDelayMs = livePresentationDelayMs; this.manifestEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); - this.tag = tag; sideloadedManifest = manifest != null; mediaPeriods = new ArrayList<>(); } @@ -554,7 +596,12 @@ public final class SsMediaSource extends BaseMediaSource @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -721,7 +768,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ manifest.isLive, /* isLive= */ manifest.isLive, manifest, - tag); + mediaItem); } else if (manifest.isLive) { if (manifest.dvrWindowLengthUs != C.TIME_UNSET && manifest.dvrWindowLengthUs > 0) { startTimeUs = Math.max(startTimeUs, endTimeUs - manifest.dvrWindowLengthUs); @@ -744,7 +791,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ true, /* isLive= */ true, manifest, - tag); + mediaItem); } else { long durationUs = manifest.durationUs != C.TIME_UNSET ? manifest.durationUs : endTimeUs - startTimeUs; @@ -758,7 +805,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ false, /* isLive= */ false, manifest, - tag); + mediaItem); } refreshSourceInfo(timeline); } diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java new file mode 100644 index 0000000000..39b1161af7 --- /dev/null +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.smoothstreaming; + +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static com.google.common.truth.Truth.assertThat; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.FileDataSource; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link SsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class SsMediaSourceTest { + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.tag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { + Object factoryTag = new Object(); + Object mediaItemTag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()).setTag(factoryTag); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @Test + public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); + SsMediaSource.Factory factory = new SsMediaSource.Factory(new FileDataSource.Factory()); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKeys() { + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()) + .setStreamKeys(Collections.singletonList(streamKey)); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotOverrideMediaItemStreamKeys() { + StreamKey mediaItemStreamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://www.google.com") + .setStreamKeys(Collections.singletonList(mediaItemStreamKey)) + .build(); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()) + .setStreamKeys( + Collections.singletonList(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + } +} From 5c52915f0c50330085caefb38146a33bff032cbd Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 28 May 2020 16:47:46 +0100 Subject: [PATCH 0335/1052] Don't splice in if segments don't overlap and segments are independent. If the sample times don't overlap and are independent, splicing makes no difference because all samples (starting from the first one, which must be a keyframe) will be appended to the end of the queue anyway. PiperOrigin-RevId: 313594372 --- .../android/exoplayer2/source/hls/HlsMediaChunk.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 7a5fade817..85f30986ef 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -62,6 +62,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * @param format The chunk format. * @param startOfPlaylistInPeriodUs The position of the playlist in the period in microseconds. * @param mediaPlaylist The media playlist from which this chunk was obtained. + * @param segmentIndexInPlaylist The index of the segment in the media playlist. * @param playlistUrl The url of the playlist from which this chunk was obtained. * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption * information is available in the master playlist. @@ -137,8 +138,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (previousChunk != null) { id3Decoder = previousChunk.id3Decoder; scratchId3Data = previousChunk.scratchId3Data; - shouldSpliceIn = - !playlistUrl.equals(previousChunk.playlistUrl) || !previousChunk.loadCompleted; + boolean canContinueWithoutSplice = + (playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted) + || (mediaPlaylist.hasIndependentSegments + && segmentStartTimeInPeriodUs >= previousChunk.endTimeUs); + shouldSpliceIn = !canContinueWithoutSplice; if (shouldSpliceIn) { sampleQueueDiscardFromIndices = previousChunk.sampleQueueDiscardFromIndices; } From 37024ae4506ab46877364bf69567ad2adf0a0e85 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 28 May 2020 17:01:19 +0100 Subject: [PATCH 0336/1052] Add named constant for group indices. PiperOrigin-RevId: 313596831 --- .../upstream/DefaultBandwidthMeter.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index e4bcc58c3b..143a2469ab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -79,6 +79,15 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList /** Default maximum weight for the sliding window. */ public static final int DEFAULT_SLIDING_WINDOW_MAX_WEIGHT = 2000; + /** Index for the Wifi group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_WIFI = 0; + /** Index for the 2G group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_2G = 1; + /** Index for the 3G group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_3G = 2; + /** Index for the 4G group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_4G = 3; + @Nullable private static DefaultBandwidthMeter singletonInstance; /** Builder for a bandwidth meter. */ @@ -199,15 +208,26 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList int[] groupIndices = getCountryGroupIndices(countryCode); SparseArray result = new SparseArray<>(/* initialCapacity= */ 6); result.append(C.NETWORK_TYPE_UNKNOWN, DEFAULT_INITIAL_BITRATE_ESTIMATE); - result.append(C.NETWORK_TYPE_WIFI, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); - result.append(C.NETWORK_TYPE_2G, DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[1]]); - result.append(C.NETWORK_TYPE_3G, DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[2]]); - result.append(C.NETWORK_TYPE_4G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); + result.append( + C.NETWORK_TYPE_WIFI, + DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[COUNTRY_GROUP_INDEX_WIFI]]); + result.append( + C.NETWORK_TYPE_2G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[COUNTRY_GROUP_INDEX_2G]]); + result.append( + C.NETWORK_TYPE_3G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[COUNTRY_GROUP_INDEX_3G]]); + result.append( + C.NETWORK_TYPE_4G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[COUNTRY_GROUP_INDEX_4G]]); // Assume default Wifi and 4G bitrate for Ethernet and 5G, respectively, to prevent using the // slower fallback. result.append( - C.NETWORK_TYPE_ETHERNET, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); - result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); + C.NETWORK_TYPE_ETHERNET, + DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[COUNTRY_GROUP_INDEX_WIFI]]); + result.append( + C.NETWORK_TYPE_5G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[COUNTRY_GROUP_INDEX_4G]]); return result; } From 0c81022aaac0c6937de88af9f265f6ff9c752cd1 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 28 May 2020 17:51:37 +0100 Subject: [PATCH 0337/1052] Make HlsMediaSource add the media item to the timeline PiperOrigin-RevId: 313605791 --- .../exoplayer2/source/hls/HlsMediaSource.java | 60 +++++--- .../source/hls/HlsMediaSourceTest.java | 135 ++++++++++++++++++ 2 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index 39fa99c498..fcf4386492 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.hls; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.net.Uri; @@ -49,7 +50,7 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -121,7 +122,7 @@ public final class HlsMediaSource extends BaseMediaSource * manifests, segments and keys. */ public Factory(HlsDataSourceFactory hlsDataSourceFactory) { - this.hlsDataSourceFactory = Assertions.checkNotNull(hlsDataSourceFactory); + this.hlsDataSourceFactory = checkNotNull(hlsDataSourceFactory); playlistParserFactory = new DefaultHlsPlaylistParserFactory(); playlistTrackerFactory = DefaultHlsPlaylistTracker.FACTORY; extractorFactory = HlsExtractorFactory.DEFAULT; @@ -332,7 +333,8 @@ public final class HlsMediaSource extends BaseMediaSource @Deprecated @Override public HlsMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setUri(uri).build()); + return createMediaSource( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build()); } /** @@ -344,18 +346,29 @@ public final class HlsMediaSource extends BaseMediaSource */ @Override public HlsMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); HlsPlaylistParserFactory playlistParserFactory = this.playlistParserFactory; List streamKeys = - !mediaItem.playbackProperties.streamKeys.isEmpty() - ? mediaItem.playbackProperties.streamKeys - : this.streamKeys; + mediaItem.playbackProperties.streamKeys.isEmpty() + ? this.streamKeys + : mediaItem.playbackProperties.streamKeys; if (!streamKeys.isEmpty()) { playlistParserFactory = new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys); } + + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsStreamKeys = + mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty(); + if (needsTag && needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); + } return new HlsMediaSource( - mediaItem.playbackProperties.uri, + mediaItem, hlsDataSourceFactory, extractorFactory, compositeSequenceableLoaderFactory, @@ -365,8 +378,7 @@ public final class HlsMediaSource extends BaseMediaSource hlsDataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory), allowChunklessPreparation, metadataType, - useSessionKeys, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + useSessionKeys); } @Override @@ -376,7 +388,8 @@ public final class HlsMediaSource extends BaseMediaSource } private final HlsExtractorFactory extractorFactory; - private final Uri manifestUri; + private final MediaItem mediaItem; + private final MediaItem.PlaybackProperties playbackProperties; private final HlsDataSourceFactory dataSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private final DrmSessionManager drmSessionManager; @@ -385,12 +398,11 @@ public final class HlsMediaSource extends BaseMediaSource private final @MetadataType int metadataType; private final boolean useSessionKeys; private final HlsPlaylistTracker playlistTracker; - @Nullable private final Object tag; @Nullable private TransferListener mediaTransferListener; private HlsMediaSource( - Uri manifestUri, + MediaItem mediaItem, HlsDataSourceFactory dataSourceFactory, HlsExtractorFactory extractorFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, @@ -399,9 +411,9 @@ public final class HlsMediaSource extends BaseMediaSource HlsPlaylistTracker playlistTracker, boolean allowChunklessPreparation, @MetadataType int metadataType, - boolean useSessionKeys, - @Nullable Object tag) { - this.manifestUri = manifestUri; + boolean useSessionKeys) { + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); + this.mediaItem = mediaItem; this.dataSourceFactory = dataSourceFactory; this.extractorFactory = extractorFactory; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; @@ -411,13 +423,17 @@ public final class HlsMediaSource extends BaseMediaSource this.allowChunklessPreparation = allowChunklessPreparation; this.metadataType = metadataType; this.useSessionKeys = useSessionKeys; - this.tag = tag; } @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -425,7 +441,7 @@ public final class HlsMediaSource extends BaseMediaSource this.mediaTransferListener = mediaTransferListener; drmSessionManager.prepare(); EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); - playlistTracker.start(manifestUri, eventDispatcher, /* listener= */ this); + playlistTracker.start(playbackProperties.uri, eventDispatcher, /* listener= */ this); } @Override @@ -477,7 +493,7 @@ public final class HlsMediaSource extends BaseMediaSource long windowDefaultStartPositionUs = playlist.startOffsetUs; // masterPlaylist is non-null because the first playlist has been fetched by now. HlsManifest manifest = - new HlsManifest(Assertions.checkNotNull(playlistTracker.getMasterPlaylist()), playlist); + new HlsManifest(checkNotNull(playlistTracker.getMasterPlaylist()), playlist); if (playlistTracker.isLive()) { long offsetFromInitialStartTimeUs = playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); @@ -511,7 +527,7 @@ public final class HlsMediaSource extends BaseMediaSource /* isDynamic= */ !playlist.hasEndTag, /* isLive= */ true, manifest, - tag); + mediaItem); } else /* not live */ { if (windowDefaultStartPositionUs == C.TIME_UNSET) { windowDefaultStartPositionUs = 0; @@ -529,7 +545,7 @@ public final class HlsMediaSource extends BaseMediaSource /* isDynamic= */ false, /* isLive= */ false, manifest, - tag); + mediaItem); } refreshSourceInfo(timeline); } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java new file mode 100644 index 0000000000..418e51dd33 --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.DataSource; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link DashMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class HlsMediaSourceTest { + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { + Object factoryTag = new Object(); + Object mediaItemTag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(factoryTag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(new Object()); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKeys() { + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)) + .setStreamKeys(Collections.singletonList(streamKey)); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotOverrideMediaItemStreamKeys() { + StreamKey mediaItemStreamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://www.google.com") + .setStreamKeys(Collections.singletonList(mediaItemStreamKey)) + .build(); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)) + .setStreamKeys( + Collections.singletonList(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + } +} From 1cfb68bf68dbc52f8841c5540007bed42699c87c Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 28 May 2020 19:09:57 +0100 Subject: [PATCH 0338/1052] Change order of RawCcExtractor init to call format before endTracks. This fixes an issue where the output track's sample format is null for rawCC captions when endTracks method is called. PiperOrigin-RevId: 313622631 --- .../android/exoplayer2/extractor/rawcc/RawCcExtractor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java index ae30231a50..4e0cefcb54 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java @@ -66,8 +66,8 @@ public final class RawCcExtractor implements Extractor { public void init(ExtractorOutput output) { output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); trackOutput = output.track(0, C.TRACK_TYPE_TEXT); - output.endTracks(); trackOutput.format(format); + output.endTracks(); } @Override From cf726f0c60f808c710fffa9a58aa6d0dc74b05fc Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 28 May 2020 19:50:27 +0100 Subject: [PATCH 0339/1052] Improve SimpleCacheTest PiperOrigin-RevId: 313630376 --- .../upstream/cache/SimpleCacheTest.java | 415 ++++++++---------- 1 file changed, 186 insertions(+), 229 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 2b43ff9d3b..670eceff57 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -18,9 +18,10 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.android.exoplayer2.C.LENGTH_UNSET; import static com.google.android.exoplayer2.util.Util.toByteArray; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doAnswer; +import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; @@ -32,7 +33,6 @@ import java.io.FileOutputStream; import java.io.IOException; import java.util.NavigableSet; import java.util.Random; -import java.util.Set; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -44,26 +44,29 @@ import org.mockito.MockitoAnnotations; @RunWith(AndroidJUnit4.class) public class SimpleCacheTest { + private static final byte[] ENCRYPTED_INDEX_KEY = Util.getUtf8Bytes("Bar12345Bar12345"); private static final String KEY_1 = "key1"; private static final String KEY_2 = "key2"; + private File testDir; private File cacheDir; @Before public void setUp() throws Exception { MockitoAnnotations.initMocks(this); - cacheDir = Util.createTempFile(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - // Delete the file. SimpleCache initialization should create a directory with the same name. - assertThat(cacheDir.delete()).isTrue(); + testDir = Util.createTempFile(ApplicationProvider.getApplicationContext(), "SimpleCacheTest"); + assertThat(testDir.delete()).isTrue(); + assertThat(testDir.mkdirs()).isTrue(); + cacheDir = new File(testDir, "cache"); } @After public void tearDown() { - Util.recursiveDelete(cacheDir); + Util.recursiveDelete(testDir); } @Test - public void cacheInitialization() { + public void newInstance_withEmptyDirectory() { SimpleCache cache = getSimpleCache(); // Cache initialization should have created a non-negative UID. @@ -76,10 +79,13 @@ public class SimpleCacheTest { cache.release(); cache = getSimpleCache(); assertThat(cache.getUid()).isEqualTo(uid); + + // Cache should be empty. + assertThat(cache.getKeys()).isEmpty(); } @Test - public void cacheInitializationError() throws IOException { + public void newInstance_withConflictingFile_fails() throws IOException { // Creating a file where the cache should be will cause an error during initialization. assertThat(cacheDir.createNewFile()).isTrue(); @@ -90,52 +96,172 @@ public class SimpleCacheTest { } @Test - public void committingOneFile() throws Exception { + public void newInstance_withExistingCacheDirectory_loadsCachedData() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - assertThat(cacheSpan1.isCached).isFalse(); - assertThat(cacheSpan1.isOpenEnded()).isTrue(); + // Write some data and metadata to the cache. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setRedirectedUri(mutations, Uri.parse("https://redirect.google.com")); + simpleCache.applyContentMetadataMutations(KEY_1, mutations); + simpleCache.release(); - assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 0)).isNull(); + // Create a new instance pointing to the same directory. + simpleCache = getSimpleCache(); - NavigableSet cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans.isEmpty()).isTrue(); - assertThat(simpleCache.getCacheSpace()).isEqualTo(0); + // Read the cached data and metadata back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0); + assertCachedDataReadCorrect(fileSpan); + assertThat(ContentMetadata.getRedirectedUri(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(Uri.parse("https://redirect.google.com")); + } + + @Test + public void newInstance_withExistingCacheInstance_fails() { + getSimpleCache(); + + // Instantiation should fail because the directory is locked by the first instance. + assertThrows(IllegalStateException.class, this::getSimpleCache); + } + + @Test + public void newInstance_withExistingCacheDirectory_resolvesInconsistentState() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_1).first()); + + // Don't release the cache. This means the index file won't have been written to disk after the + // span was removed. Move the cache directory instead, so we can reload it without failing the + // folder locking check. + File cacheDir2 = new File(testDir, "cache2"); + cacheDir.renameTo(cacheDir2); + + // Create a new instance pointing to the new directory. + simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor()); + + // The entry for KEY_1 should have been removed when the cache was reloaded. + assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); + } + + @Test + public void newInstance_withEncryptedIndex() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.release(); + + // Create a new instance pointing to the same directory. + simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + + // Read the cached data back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0); + assertCachedDataReadCorrect(fileSpan); + } + + @Test + public void newInstance_withEncryptedIndexAndWrongKey_clearsCache() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + + // Write data. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.release(); + + // Create a new instance pointing to the same directory, with a different key. + simpleCache = getEncryptedSimpleCache(Util.getUtf8Bytes("Foo12345Foo12345")); + + // Cache should be cleared. + assertThat(simpleCache.getKeys()).isEmpty(); assertNoCacheFiles(cacheDir); + } + @Test + public void newInstance_withEncryptedIndexAndNoKey_clearsCache() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + + // Write data. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.release(); + + // Create a new instance pointing to the same directory, with no key. + simpleCache = getSimpleCache(); + + // Cache should be cleared. + assertThat(simpleCache.getKeys()).isEmpty(); + assertNoCacheFiles(cacheDir); + } + + @Test + public void write_oneLock_oneFile_thenRead() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0); + assertThat(holeSpan1.isCached).isFalse(); + assertThat(holeSpan1.isOpenEnded()).isTrue(); addCache(simpleCache, KEY_1, 0, 15); - Set cachedKeys = simpleCache.getKeys(); - assertThat(cachedKeys).containsExactly(KEY_1); - cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans).contains(cacheSpan1); + CacheSpan readSpan = simpleCache.startReadWrite(KEY_1, 0); + assertThat(readSpan.position).isEqualTo(0); + assertThat(readSpan.length).isEqualTo(15); + assertCachedDataReadCorrect(readSpan); assertThat(simpleCache.getCacheSpace()).isEqualTo(15); - - simpleCache.releaseHoleSpan(cacheSpan1); - - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertThat(cacheSpan2.isCached).isTrue(); - assertThat(cacheSpan2.isOpenEnded()).isFalse(); - assertThat(cacheSpan2.length).isEqualTo(15); - assertCachedDataReadCorrect(cacheSpan2); } @Test - public void readCacheWithoutReleasingWriteCacheSpan() throws Exception { + public void write_oneLock_twoFiles_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); - simpleCache.releaseHoleSpan(cacheSpan1); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 7); + addCache(simpleCache, KEY_1, 7, 8); + + CacheSpan readSpan1 = simpleCache.startReadWrite(KEY_1, 0); + assertThat(readSpan1.position).isEqualTo(0); + assertThat(readSpan1.length).isEqualTo(7); + assertCachedDataReadCorrect(readSpan1); + CacheSpan readSpan2 = simpleCache.startReadWrite(KEY_1, 7); + assertThat(readSpan2.position).isEqualTo(7); + assertThat(readSpan2.length).isEqualTo(8); + assertCachedDataReadCorrect(readSpan2); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); } @Test - public void setGetContentMetadata() throws Exception { + public void write_differentKeyLocked_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 50); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_2, 50); + assertThat(holeSpan1.isCached).isFalse(); + assertThat(holeSpan1.isOpenEnded()).isTrue(); + addCache(simpleCache, KEY_2, 0, 15); + + CacheSpan readSpan = simpleCache.startReadWrite(KEY_2, 0); + assertThat(readSpan.length).isEqualTo(15); + assertCachedDataReadCorrect(readSpan); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + } + + @Test + public void write_sameKeyLocked_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 50); + + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 25)).isNull(); + } + + @Test + public void applyContentMetadataMutations_setsContentLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) .isEqualTo(LENGTH_UNSET); @@ -144,85 +270,6 @@ public class SimpleCacheTest { simpleCache.applyContentMetadataMutations(KEY_1, mutations); assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) .isEqualTo(15); - - simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - - mutations = new ContentMetadataMutations(); - ContentMetadataMutations.setContentLength(mutations, 150); - simpleCache.applyContentMetadataMutations(KEY_1, mutations); - assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) - .isEqualTo(150); - - addCache(simpleCache, KEY_1, 140, 10); - - simpleCache.release(); - - // Check if values are kept after cache is reloaded. - SimpleCache simpleCache2 = getSimpleCache(); - assertThat(ContentMetadata.getContentLength(simpleCache2.getContentMetadata(KEY_1))) - .isEqualTo(150); - - // Removing the last span shouldn't cause the length be change next time cache loaded - CacheSpan lastSpan = simpleCache2.startReadWrite(KEY_1, 145); - simpleCache2.removeSpan(lastSpan); - simpleCache2.release(); - simpleCache2 = getSimpleCache(); - assertThat(ContentMetadata.getContentLength(simpleCache2.getContentMetadata(KEY_1))) - .isEqualTo(150); - } - - @Test - public void reloadCache() throws Exception { - SimpleCache simpleCache = getSimpleCache(); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - simpleCache = getSimpleCache(); - - // read data back - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); - } - - @Test - public void reloadCacheWithoutRelease() throws Exception { - SimpleCache simpleCache = getSimpleCache(); - - // Write data for KEY_1. - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - // Write and remove data for KEY_2. - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_2, 0); - addCache(simpleCache, KEY_2, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan2); - simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_2).first()); - - // Don't release the cache. This means the index file won't have been written to disk after the - // data for KEY_2 was removed. Move the cache instead, so we can reload it without failing the - // folder locking check. - File cacheDir2 = - Util.createTempFile(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cacheDir2.delete(); - cacheDir.renameTo(cacheDir2); - - // Reload the cache from its new location. - simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor()); - - // Read data back for KEY_1. - CacheSpan cacheSpan3 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan3); - - // Check the entry for KEY_2 was removed when the cache was reloaded. - assertThat(simpleCache.getCachedSpans(KEY_2)).isEmpty(); - - Util.recursiveDelete(cacheDir2); } @Test @@ -241,64 +288,6 @@ public class SimpleCacheTest { assertThat(simpleCache.getCachedSpans(KEY_2)).hasSize(1); } - @Test - public void encryptedIndex() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - simpleCache = getEncryptedSimpleCache(key); - - // read data back - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); - } - - @Test - public void encryptedIndexWrongKey() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - byte[] key2 = Util.getUtf8Bytes("Foo12345Foo12345"); // 128 bit key - simpleCache = getEncryptedSimpleCache(key2); - - // Cache should be cleared - assertThat(simpleCache.getKeys()).isEmpty(); - assertNoCacheFiles(cacheDir); - } - - @Test - public void encryptedIndexLostKey() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - simpleCache = getSimpleCache(); - - // Cache should be cleared - assertThat(simpleCache.getKeys()).isEmpty(); - assertNoCacheFiles(cacheDir); - } - @Test public void getCachedLength_noCachedContent_returnsNegativeMaxHoleLength() { SimpleCache simpleCache = getSimpleCache(); @@ -320,9 +309,9 @@ public class SimpleCacheTest { @Test public void getCachedLength_returnsNegativeHoleLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); addCache(simpleCache, KEY_1, /* position= */ 50, /* length= */ 50); - simpleCache.releaseHoleSpan(cacheSpan); + simpleCache.releaseHoleSpan(holeSpan); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) .isEqualTo(-50); @@ -341,9 +330,9 @@ public class SimpleCacheTest { @Test public void getCachedLength_returnsCachedLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 50); - simpleCache.releaseHoleSpan(cacheSpan); + simpleCache.releaseHoleSpan(holeSpan); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) .isEqualTo(50); @@ -364,10 +353,10 @@ public class SimpleCacheTest { @Test public void getCachedLength_withMultipleAdjacentSpans_returnsCachedLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); - simpleCache.releaseHoleSpan(cacheSpan); + simpleCache.releaseHoleSpan(holeSpan); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) .isEqualTo(50); @@ -388,10 +377,10 @@ public class SimpleCacheTest { @Test public void getCachedLength_withMultipleNonAdjacentSpans_returnsCachedLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); - simpleCache.releaseHoleSpan(cacheSpan); + simpleCache.releaseHoleSpan(holeSpan); assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) .isEqualTo(10); @@ -430,10 +419,10 @@ public class SimpleCacheTest { @Test public void getCachedBytes_withMultipleAdjacentSpans_returnsCachedBytes() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); - simpleCache.releaseHoleSpan(cacheSpan); + simpleCache.releaseHoleSpan(holeSpan); assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) .isEqualTo(50); @@ -454,10 +443,10 @@ public class SimpleCacheTest { @Test public void getCachedBytes_withMultipleNonAdjacentSpans_returnsCachedBytes() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); - simpleCache.releaseHoleSpan(cacheSpan); + simpleCache.releaseHoleSpan(holeSpan); assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) .isEqualTo(45); @@ -477,7 +466,7 @@ public class SimpleCacheTest { /* Tests https://github.com/google/ExoPlayer/issues/3260 case. */ @Test - public void exceptionDuringEvictionByLeastRecentlyUsedCacheEvictorNotHang() throws Exception { + public void exceptionDuringIndexStore_doesNotPreventEviction() throws Exception { CachedContentIndex contentIndex = Mockito.spy(new CachedContentIndex(TestUtil.getInMemoryDatabaseProvider())); SimpleCache simpleCache = @@ -485,7 +474,7 @@ public class SimpleCacheTest { cacheDir, new LeastRecentlyUsedCacheEvictor(20), contentIndex, /* fileIndex= */ null); // Add some content. - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); addCache(simpleCache, KEY_1, 0, 15); // Make index.store() throw exception from now on. @@ -496,55 +485,24 @@ public class SimpleCacheTest { .when(contentIndex) .store(); - // Adding more content will make LeastRecentlyUsedCacheEvictor evict previous content. - try { - addCache(simpleCache, KEY_1, 15, 15); - assertWithMessage("Exception was expected").fail(); - } catch (CacheException e) { - // do nothing. - } + // Adding more content should evict previous content. + assertThrows(CacheException.class, () -> addCache(simpleCache, KEY_1, 15, 15)); + simpleCache.releaseHoleSpan(holeSpan); - simpleCache.releaseHoleSpan(cacheSpan); - - // Although store() has failed, it should remove the first span and add the new one. + // Although store() failed, the first span should have been removed and the new one added. NavigableSet cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans).isNotEmpty(); assertThat(cachedSpans).hasSize(1); - assertThat(cachedSpans.pollFirst().position).isEqualTo(15); + CacheSpan fileSpan = cachedSpans.first(); + assertThat(fileSpan.position).isEqualTo(15); + assertThat(fileSpan.length).isEqualTo(15); } @Test - public void usingReleasedSimpleCacheThrowsException() throws Exception { + public void usingReleasedCache_throwsException() { SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); simpleCache.release(); - - try { - simpleCache.startReadWriteNonBlocking(KEY_1, 0); - assertWithMessage("Exception was expected").fail(); - } catch (RuntimeException e) { - // Expected. Do nothing. - } - } - - @Test - public void multipleSimpleCacheWithSameCacheDirThrowsException() throws Exception { - new SimpleCache(cacheDir, new NoOpCacheEvictor()); - - try { - new SimpleCache(cacheDir, new NoOpCacheEvictor()); - assertWithMessage("Exception was expected").fail(); - } catch (IllegalStateException e) { - // Expected. Do nothing. - } - } - - @Test - public void multipleSimpleCacheWithSameCacheDirDoesNotThrowsExceptionAfterRelease() - throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - simpleCache.release(); - - new SimpleCache(cacheDir, new NoOpCacheEvictor()); + assertThrows( + IllegalStateException.class, () -> simpleCache.startReadWriteNonBlocking(KEY_1, 0)); } private SimpleCache getSimpleCache() { @@ -588,8 +546,7 @@ public class SimpleCacheTest { private static byte[] generateData(String key, int position, int length) { byte[] bytes = new byte[length]; - new Random((long) (key.hashCode() ^ position)).nextBytes(bytes); + new Random(key.hashCode() ^ position).nextBytes(bytes); return bytes; } - } From 20ace937066b98e5daec74a1077521a29790b474 Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 29 May 2020 13:54:31 +0100 Subject: [PATCH 0340/1052] Add a TODO to enforce ParsableByteArray.limit I was trying to understand why this isn't always checked and didn't realise there was already a bug tracking this - this way a future confused reader knows something isn't working quite as intended. PiperOrigin-RevId: 313765935 --- .../com/google/android/exoplayer2/util/ParsableByteArray.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index 67686ad64f..2acab348ad 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -29,6 +29,8 @@ public final class ParsableByteArray { public byte[] data; private int position; + + // TODO(internal b/147657250): Enforce this limit on all read methods. private int limit; /** Creates a new instance that initially has no backing data. */ From 52e39cd7558d5e55c65d1f737acf7ca5f8d157ab Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 29 May 2020 14:40:08 +0100 Subject: [PATCH 0341/1052] Handle factory attributes consistently This change applies the same approach of handling tag/streamKeys from factories like in the SmoothStreaming and Hls factories. It is functionally equivalent but improves readability. PiperOrigin-RevId: 313771318 --- .../exoplayer2/source/dash/DashMediaSource.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index e2954fb6ff..f60e1c15a2 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -376,15 +376,21 @@ public final class DashMediaSource extends BaseMediaSource { manifestParser = new DashManifestParser(); } List streamKeys = - !mediaItem.playbackProperties.streamKeys.isEmpty() - ? mediaItem.playbackProperties.streamKeys - : this.streamKeys; + mediaItem.playbackProperties.streamKeys.isEmpty() + ? this.streamKeys + : mediaItem.playbackProperties.streamKeys; if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } - if (mediaItem.playbackProperties.tag == null && tag != null) { + + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsStreamKeys = + mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty(); + if (needsTag && needsStreamKeys) { mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); - } else if (mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty()) { + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsStreamKeys) { mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); } return new DashMediaSource( From 235df090fd25d3076e334955c2dd65e4c68a157b Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 29 May 2020 18:13:05 +0100 Subject: [PATCH 0342/1052] Support multiple non-overlapping write locks in SimpleCache Issue: #5978 PiperOrigin-RevId: 313802629 --- RELEASENOTES.md | 2 + .../exoplayer2/offline/SegmentDownloader.java | 8 + .../exoplayer2/upstream/cache/Cache.java | 25 ++- .../upstream/cache/CacheDataSource.java | 4 +- .../exoplayer2/upstream/cache/CacheSpan.java | 4 + .../upstream/cache/CachedContent.java | 132 +++++++++++-- .../upstream/cache/CachedContentIndex.java | 2 +- .../upstream/cache/SimpleCache.java | 32 ++- .../upstream/cache/SimpleCacheSpan.java | 30 +-- .../upstream/cache/CacheDataSourceTest.java | 2 +- .../cache/CachedContentIndexTest.java | 2 +- .../upstream/cache/SimpleCacheTest.java | 185 +++++++++++++++--- 12 files changed, 329 insertions(+), 99 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 98e5847ba5..c1b45b4b19 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -134,6 +134,8 @@ * Downloads and caching: * Merge downloads in `SegmentDownloader` to improve overall download speed ([#5978](https://github.com/google/ExoPlayer/issues/5978)). + * Support multiple non-overlapping write locks for the same key in + `SimpleCache`. * Replace `CacheDataSinkFactory` and `CacheDataSourceFactory` with `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively. * Remove `DownloadConstructorHelper` and use `CacheDataSource.Factory` diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 28ed994168..a67123ca05 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -31,8 +31,10 @@ import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; import com.google.android.exoplayer2.upstream.cache.CacheWriter; import com.google.android.exoplayer2.upstream.cache.ContentMetadata; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.SystemClock; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -218,17 +220,23 @@ public abstract class SegmentDownloader> impleme } } + long timer = 0; + @Override public final void remove() { Cache cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); CacheKeyFactory cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForRemovingDownload(); try { + timer = SystemClock.DEFAULT.elapsedRealtime(); M manifest = getManifest(dataSource, manifestDataSpec); + Log.e("XXX", "E1\t" + (SystemClock.DEFAULT.elapsedRealtime() - timer)); + timer = SystemClock.DEFAULT.elapsedRealtime(); List segments = getSegments(dataSource, manifest, true); for (int i = 0; i < segments.size(); i++) { cache.removeResource(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); } + Log.e("XXX", "E2\t" + (SystemClock.DEFAULT.elapsedRealtime() - timer)); } catch (IOException e) { // Ignore exceptions when removing. } finally { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index 33f0dc35f2..c917929111 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -165,7 +165,7 @@ public interface Cache { * defines the file in which the data is stored. {@link CacheSpan#isCached} is true. The caller * may read from the cache file, but does not acquire any locks. * - *

    If there is no cache entry overlapping {@code offset}, then the returned {@link CacheSpan} + *

    If there is no cache entry overlapping {@code position}, then the returned {@link CacheSpan} * defines a hole in the cache starting at {@code position} into which the caller may write as it * obtains the data from some other source. The returned {@link CacheSpan} serves as a lock. * Whilst the caller holds the lock it may write data into the hole. It may split data into @@ -177,31 +177,40 @@ public interface Cache { * * @param key The cache key of the resource. * @param position The starting position in the resource from which data is required. + * @param length The length of the data being requested, or {@link C#LENGTH_UNSET} if unbounded. + * The length is ignored in the case of a cache hit. In the case of a cache miss, it defines + * the maximum length of the hole {@link CacheSpan} that's returned. Cache implementations may + * support parallel writes into non-overlapping holes, and so passing the actual required + * length should be preferred to passing {@link C#LENGTH_UNSET} when possible. * @return The {@link CacheSpan}. * @throws InterruptedException If the thread was interrupted. * @throws CacheException If an error is encountered. */ @WorkerThread - CacheSpan startReadWrite(String key, long position) throws InterruptedException, CacheException; + CacheSpan startReadWrite(String key, long position, long length) + throws InterruptedException, CacheException; /** - * Same as {@link #startReadWrite(String, long)}. However, if the cache entry is locked, then - * instead of blocking, this method will return null as the {@link CacheSpan}. + * Same as {@link #startReadWrite(String, long, long)}. However, if the cache entry is locked, + * then instead of blocking, this method will return null as the {@link CacheSpan}. * *

    This method may be slow and shouldn't normally be called on the main thread. * * @param key The cache key of the resource. * @param position The starting position in the resource from which data is required. + * @param length The length of the data being requested, or {@link C#LENGTH_UNSET} if unbounded. + * The length is ignored in the case of a cache hit. In the case of a cache miss, it defines + * the range of data locked by the returned {@link CacheSpan}. * @return The {@link CacheSpan}. Or null if the cache entry is locked. * @throws CacheException If an error is encountered. */ @WorkerThread @Nullable - CacheSpan startReadWriteNonBlocking(String key, long position) throws CacheException; + CacheSpan startReadWriteNonBlocking(String key, long position, long length) throws CacheException; /** * Obtains a cache file into which data can be written. Must only be called when holding a - * corresponding hole {@link CacheSpan} obtained from {@link #startReadWrite(String, long)}. + * corresponding hole {@link CacheSpan} obtained from {@link #startReadWrite(String, long, long)}. * *

    This method may be slow and shouldn't normally be called on the main thread. * @@ -217,7 +226,7 @@ public interface Cache { /** * Commits a file into the cache. Must only be called when holding a corresponding hole {@link - * CacheSpan} obtained from {@link #startReadWrite(String, long)}. + * CacheSpan} obtained from {@link #startReadWrite(String, long, long)}. * *

    This method may be slow and shouldn't normally be called on the main thread. * @@ -229,7 +238,7 @@ public interface Cache { void commitFile(File file, long length) throws CacheException; /** - * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which + * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long, long)} which * corresponded to a hole in the cache. * * @param holeSpan The {@link CacheSpan} being released. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index d20aaa0b63..e1e2e5194b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -691,13 +691,13 @@ public final class CacheDataSource implements DataSource { nextSpan = null; } else if (blockOnCache) { try { - nextSpan = cache.startReadWrite(key, readPosition); + nextSpan = cache.startReadWrite(key, readPosition, bytesRemaining); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new InterruptedIOException(); } } else { - nextSpan = cache.startReadWriteNonBlocking(key, readPosition); + nextSpan = cache.startReadWriteNonBlocking(key, readPosition, bytesRemaining); } DataSpec nextDataSpec; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java index a4dacbe95c..978b5bacfa 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java @@ -98,4 +98,8 @@ public class CacheSpan implements Comparable { return startOffsetDiff == 0 ? 0 : ((startOffsetDiff < 0) ? -1 : 1); } + @Override + public String toString() { + return "[" + position + ", " + length + "]"; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 82dfd3fa99..352483fe8f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -19,8 +19,10 @@ import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkState; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Log; import java.io.File; +import java.util.ArrayList; import java.util.TreeSet; /** Defines the cached content for a single resource. */ @@ -34,10 +36,11 @@ import java.util.TreeSet; public final String key; /** The cached spans of this content. */ private final TreeSet cachedSpans; + /** Currently locked ranges. */ + private final ArrayList lockedRanges; + /** Metadata values. */ private DefaultContentMetadata metadata; - /** Whether the content is locked. */ - private boolean locked; /** * Creates a CachedContent. @@ -53,7 +56,8 @@ import java.util.TreeSet; this.id = id; this.key = key; this.metadata = metadata; - this.cachedSpans = new TreeSet<>(); + cachedSpans = new TreeSet<>(); + lockedRanges = new ArrayList<>(); } /** Returns the metadata. */ @@ -72,14 +76,58 @@ import java.util.TreeSet; return !metadata.equals(oldMetadata); } - /** Returns whether the content is locked. */ - public boolean isLocked() { - return locked; + /** Returns whether the entire resource is fully unlocked. */ + public boolean isFullyUnlocked() { + return lockedRanges.isEmpty(); } - /** Sets the locked state of the content. */ - public void setLocked(boolean locked) { - this.locked = locked; + /** + * Returns whether the specified range of the resource is fully locked by a single lock. + * + * @param position The position of the range. + * @param length The length of the range, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether the range is fully locked by a single lock. + */ + public boolean isFullyLocked(long position, long length) { + for (int i = 0; i < lockedRanges.size(); i++) { + if (lockedRanges.get(i).contains(position, length)) { + return true; + } + } + return false; + } + + /** + * Attempts to lock the specified range of the resource. + * + * @param position The position of the range. + * @param length The length of the range, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether the range was successfully locked. + */ + public boolean lockRange(long position, long length) { + for (int i = 0; i < lockedRanges.size(); i++) { + if (lockedRanges.get(i).intersects(position, length)) { + return false; + } + } + lockedRanges.add(new Range(position, length)); + return true; + } + + /** + * Unlocks the currently locked range starting at the specified position. + * + * @param position The starting position of the locked range. + * @throws IllegalStateException If there was no locked range starting at the specified position. + */ + public void unlockRange(long position) { + for (int i = 0; i < lockedRanges.size(); i++) { + if (lockedRanges.get(i).position == position) { + lockedRanges.remove(i); + return; + } + } + throw new IllegalStateException(); } /** Adds the given {@link SimpleCacheSpan} which contains a part of the content. */ @@ -93,18 +141,25 @@ import java.util.TreeSet; } /** - * Returns the span containing the position. If there isn't one, it returns a hole span - * which defines the maximum extents of the hole in the cache. + * Returns the cache span corresponding to the provided range. See {@link + * Cache#startReadWrite(String, long, long)} for detailed descriptions of the returned spans. + * + * @param position The position of the span being requested. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded. + * @return The corresponding cache {@link SimpleCacheSpan}. */ - public SimpleCacheSpan getSpan(long position) { + public SimpleCacheSpan getSpan(long position, long length) { SimpleCacheSpan lookupSpan = SimpleCacheSpan.createLookup(key, position); SimpleCacheSpan floorSpan = cachedSpans.floor(lookupSpan); if (floorSpan != null && floorSpan.position + floorSpan.length > position) { return floorSpan; } SimpleCacheSpan ceilSpan = cachedSpans.ceiling(lookupSpan); - return ceilSpan == null ? SimpleCacheSpan.createOpenHole(key, position) - : SimpleCacheSpan.createClosedHole(key, position, ceilSpan.position - position); + if (ceilSpan != null) { + long holeLength = ceilSpan.position - position; + length = length == C.LENGTH_UNSET ? holeLength : Math.min(holeLength, length); + } + return SimpleCacheSpan.createHole(key, position, length); } /** @@ -121,7 +176,7 @@ import java.util.TreeSet; public long getCachedBytesLength(long position, long length) { checkArgument(position >= 0); checkArgument(length >= 0); - SimpleCacheSpan span = getSpan(position); + SimpleCacheSpan span = getSpan(position, length); if (span.isHoleSpan()) { // We don't have a span covering the start of the queried region. return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length); @@ -215,4 +270,51 @@ import java.util.TreeSet; && cachedSpans.equals(that.cachedSpans) && metadata.equals(that.metadata); } + + private static final class Range { + + /** The starting position of the range. */ + public final long position; + /** The length of the range, or {@link C#LENGTH_UNSET} if unbounded. */ + public final long length; + + public Range(long position, long length) { + this.position = position; + this.length = length; + } + + /** + * Returns whether this range fully contains the range specified by {@code otherPosition} and + * {@code otherLength}. + * + * @param otherPosition The position of the range to check. + * @param otherLength The length of the range to check, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether this range fully contains the specified range. + */ + public boolean contains(long otherPosition, long otherLength) { + if (length == C.LENGTH_UNSET) { + return otherPosition >= position; + } else if (otherLength == C.LENGTH_UNSET) { + return false; + } else { + return position <= otherPosition && (otherPosition + otherLength) <= (position + length); + } + } + + /** + * Returns whether this range intersects with the range specified by {@code otherPosition} and + * {@code otherLength}. + * + * @param otherPosition The position of the range to check. + * @param otherLength The length of the range to check, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether this range intersects with the specified range. + */ + public boolean intersects(long otherPosition, long otherLength) { + if (position <= otherPosition) { + return length == C.LENGTH_UNSET || position + length > otherPosition; + } else { + return otherLength == C.LENGTH_UNSET || otherPosition + otherLength > position; + } + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java index 62c831ca11..8bf7b9d2d6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -273,7 +273,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; */ public void maybeRemove(String key) { @Nullable CachedContent cachedContent = keyToContent.get(key); - if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) { + if (cachedContent != null && cachedContent.isEmpty() && cachedContent.isFullyUnlocked()) { keyToContent.remove(key); int id = cachedContent.id; boolean neverStored = newIds.get(id); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 1cb6d13fc0..d7038c3a3d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -353,13 +353,13 @@ public final class SimpleCache implements Cache { } @Override - public synchronized CacheSpan startReadWrite(String key, long position) + public synchronized CacheSpan startReadWrite(String key, long position, long length) throws InterruptedException, CacheException { Assertions.checkState(!released); checkInitialization(); while (true) { - CacheSpan span = startReadWriteNonBlocking(key, position); + CacheSpan span = startReadWriteNonBlocking(key, position, length); if (span != null) { return span; } else { @@ -375,12 +375,12 @@ public final class SimpleCache implements Cache { @Override @Nullable - public synchronized CacheSpan startReadWriteNonBlocking(String key, long position) + public synchronized CacheSpan startReadWriteNonBlocking(String key, long position, long length) throws CacheException { Assertions.checkState(!released); checkInitialization(); - SimpleCacheSpan span = getSpan(key, position); + SimpleCacheSpan span = getSpan(key, position, length); if (span.isCached) { // Read case. @@ -388,9 +388,8 @@ public final class SimpleCache implements Cache { } CachedContent cachedContent = contentIndex.getOrAdd(key); - if (!cachedContent.isLocked()) { + if (cachedContent.lockRange(position, span.length)) { // Write case. - cachedContent.setLocked(true); return span; } @@ -405,7 +404,7 @@ public final class SimpleCache implements Cache { CachedContent cachedContent = contentIndex.get(key); Assertions.checkNotNull(cachedContent); - Assertions.checkState(cachedContent.isLocked()); + Assertions.checkState(cachedContent.isFullyLocked(position, length)); if (!cacheDir.exists()) { // For some reason the cache directory doesn't exist. Make a best effort to create it. cacheDir.mkdirs(); @@ -435,7 +434,7 @@ public final class SimpleCache implements Cache { SimpleCacheSpan span = Assertions.checkNotNull(SimpleCacheSpan.createCacheEntry(file, length, contentIndex)); CachedContent cachedContent = Assertions.checkNotNull(contentIndex.get(span.key)); - Assertions.checkState(cachedContent.isLocked()); + Assertions.checkState(cachedContent.isFullyLocked(span.position, span.length)); // Check if the span conflicts with the set content length long contentLength = ContentMetadata.getContentLength(cachedContent.getMetadata()); @@ -464,8 +463,7 @@ public final class SimpleCache implements Cache { public synchronized void releaseHoleSpan(CacheSpan holeSpan) { Assertions.checkState(!released); CachedContent cachedContent = Assertions.checkNotNull(contentIndex.get(holeSpan.key)); - Assertions.checkState(cachedContent.isLocked()); - cachedContent.setLocked(false); + cachedContent.unlockRange(holeSpan.position); contentIndex.maybeRemove(cachedContent.key); notifyAll(); } @@ -688,23 +686,21 @@ public final class SimpleCache implements Cache { } /** - * Returns the cache span corresponding to the provided lookup span. - * - *

    If the lookup position is contained by an existing entry in the cache, then the returned - * span defines the file in which the data is stored. If the lookup position is not contained by - * an existing entry, then the returned span defines the maximum extents of the hole in the cache. + * Returns the cache span corresponding to the provided key and range. See {@link + * Cache#startReadWrite(String, long, long)} for detailed descriptions of the returned spans. * * @param key The key of the span being requested. * @param position The position of the span being requested. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded. * @return The corresponding cache {@link SimpleCacheSpan}. */ - private SimpleCacheSpan getSpan(String key, long position) { + private SimpleCacheSpan getSpan(String key, long position, long length) { @Nullable CachedContent cachedContent = contentIndex.get(key); if (cachedContent == null) { - return SimpleCacheSpan.createOpenHole(key, position); + return SimpleCacheSpan.createHole(key, position, length); } while (true) { - SimpleCacheSpan span = cachedContent.getSpan(position); + SimpleCacheSpan span = cachedContent.getSpan(position, length); if (span.isCached && span.file.length() != span.length) { // The file has been modified or deleted underneath us. It's likely that other files will // have been modified too, so scan the whole in-memory representation. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java index 3a5279c949..d02f7c0988 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -54,7 +54,7 @@ import java.util.regex.Pattern; * Creates a lookup span. * * @param key The cache key of the resource. - * @param position The position of the {@link CacheSpan} in the resource. + * @param position The position of the span in the resource. * @return The span. */ public static SimpleCacheSpan createLookup(String key, long position) { @@ -62,25 +62,14 @@ import java.util.regex.Pattern; } /** - * Creates an open hole span. + * Creates a hole span. * * @param key The cache key of the resource. - * @param position The position of the {@link CacheSpan} in the resource. - * @return The span. + * @param position The position of the span in the resource. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded. + * @return The hole span. */ - public static SimpleCacheSpan createOpenHole(String key, long position) { - return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null); - } - - /** - * Creates a closed hole span. - * - * @param key The cache key of the resource. - * @param position The position of the {@link CacheSpan} in the resource. - * @param length The length of the {@link CacheSpan}. - * @return The span. - */ - public static SimpleCacheSpan createClosedHole(String key, long position, long length) { + public static SimpleCacheSpan createHole(String key, long position, long length) { return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null); } @@ -191,12 +180,11 @@ import java.util.regex.Pattern; /** * @param key The cache key of the resource. - * @param position The position of the {@link CacheSpan} in the resource. - * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an - * open-ended hole. + * @param position The position of the span in the resource. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if this is an open-ended hole. * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link * #isCached} is false. - * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole. + * @param file The file corresponding to this span, or null if it's a hole. */ private SimpleCacheSpan( String key, long position, long length, long lastTouchTimestamp, @Nullable File file) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index d8a7a03406..328d80bf48 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -384,7 +384,7 @@ public final class CacheDataSourceTest { .appendReadData(1); // Lock the content on the cache. - CacheSpan cacheSpan = cache.startReadWriteNonBlocking(defaultCacheKey, 0); + CacheSpan cacheSpan = cache.startReadWriteNonBlocking(defaultCacheKey, 0, C.LENGTH_UNSET); assertThat(cacheSpan).isNotNull(); assertThat(cacheSpan.isHoleSpan()).isTrue(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java index bbb372b5e2..1237d3a312 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java @@ -301,7 +301,7 @@ public class CachedContentIndexTest { public void cantRemoveLockedCachedContent() { CachedContentIndex index = newInstance(); CachedContent cachedContent = index.getOrAdd("key1"); - cachedContent.setLocked(true); + cachedContent.lockRange(0, 1); index.maybeRemove(cachedContent.key); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 670eceff57..fce14794eb 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -100,7 +100,7 @@ public class SimpleCacheTest { SimpleCache simpleCache = getSimpleCache(); // Write some data and metadata to the cache. - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); simpleCache.releaseHoleSpan(holeSpan); ContentMetadataMutations mutations = new ContentMetadataMutations(); @@ -112,7 +112,7 @@ public class SimpleCacheTest { simpleCache = getSimpleCache(); // Read the cached data and metadata back. - CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); assertCachedDataReadCorrect(fileSpan); assertThat(ContentMetadata.getRedirectedUri(simpleCache.getContentMetadata(KEY_1))) .isEqualTo(Uri.parse("https://redirect.google.com")); @@ -130,7 +130,7 @@ public class SimpleCacheTest { public void newInstance_withExistingCacheDirectory_resolvesInconsistentState() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); simpleCache.releaseHoleSpan(holeSpan); simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_1).first()); @@ -151,7 +151,7 @@ public class SimpleCacheTest { @Test public void newInstance_withEncryptedIndex() throws Exception { SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); simpleCache.releaseHoleSpan(holeSpan); simpleCache.release(); @@ -160,7 +160,7 @@ public class SimpleCacheTest { simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); // Read the cached data back. - CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); assertCachedDataReadCorrect(fileSpan); } @@ -169,7 +169,7 @@ public class SimpleCacheTest { SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); // Write data. - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); simpleCache.releaseHoleSpan(holeSpan); simpleCache.release(); @@ -187,7 +187,7 @@ public class SimpleCacheTest { SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); // Write data. - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); simpleCache.releaseHoleSpan(holeSpan); simpleCache.release(); @@ -204,59 +204,179 @@ public class SimpleCacheTest { public void write_oneLock_oneFile_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0); - assertThat(holeSpan1.isCached).isFalse(); - assertThat(holeSpan1.isOpenEnded()).isTrue(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(holeSpan.isCached).isFalse(); + assertThat(holeSpan.isOpenEnded()).isTrue(); addCache(simpleCache, KEY_1, 0, 15); - CacheSpan readSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan readSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); assertThat(readSpan.position).isEqualTo(0); assertThat(readSpan.length).isEqualTo(15); assertCachedDataReadCorrect(readSpan); assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan); } @Test public void write_oneLock_twoFiles_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 7); addCache(simpleCache, KEY_1, 7, 8); - CacheSpan readSpan1 = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan readSpan1 = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); assertThat(readSpan1.position).isEqualTo(0); assertThat(readSpan1.length).isEqualTo(7); assertCachedDataReadCorrect(readSpan1); - CacheSpan readSpan2 = simpleCache.startReadWrite(KEY_1, 7); + CacheSpan readSpan2 = simpleCache.startReadWrite(KEY_1, 7, LENGTH_UNSET); assertThat(readSpan2.position).isEqualTo(7); assertThat(readSpan2.length).isEqualTo(8); assertCachedDataReadCorrect(readSpan2); assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_twoLocks_twoFiles_thenRead() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, 7); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 7, 8); + + addCache(simpleCache, KEY_1, 0, 7); + addCache(simpleCache, KEY_1, 7, 8); + + CacheSpan readSpan1 = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(readSpan1.position).isEqualTo(0); + assertThat(readSpan1.length).isEqualTo(7); + assertCachedDataReadCorrect(readSpan1); + CacheSpan readSpan2 = simpleCache.startReadWrite(KEY_1, 7, LENGTH_UNSET); + assertThat(readSpan2.position).isEqualTo(7); + assertThat(readSpan2.length).isEqualTo(8); + assertCachedDataReadCorrect(readSpan2); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); } @Test public void write_differentKeyLocked_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 50); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); - CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_2, 50); - assertThat(holeSpan1.isCached).isFalse(); - assertThat(holeSpan1.isOpenEnded()).isTrue(); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_2, 0, LENGTH_UNSET); + assertThat(holeSpan2.isCached).isFalse(); + assertThat(holeSpan2.isOpenEnded()).isTrue(); addCache(simpleCache, KEY_2, 0, 15); - CacheSpan readSpan = simpleCache.startReadWrite(KEY_2, 0); + CacheSpan readSpan = simpleCache.startReadWrite(KEY_2, 0, LENGTH_UNSET); assertThat(readSpan.length).isEqualTo(15); assertCachedDataReadCorrect(readSpan); assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); } @Test - public void write_sameKeyLocked_fails() throws Exception { + public void write_oneLock_fileExceedsLock_fails() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 50); - assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 25)).isNull(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, 10); + + assertThrows(IllegalStateException.class, () -> addCache(simpleCache, KEY_1, 0, 11)); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_twoLocks_oneFileSpanningBothLocks_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, 7); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 7, 8); + + assertThrows(IllegalStateException.class, () -> addCache(simpleCache, KEY_1, 0, 15)); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_unboundedRangeLocked_lockingOverlappingRange_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 50, LENGTH_UNSET); + + // Overlapping cannot be locked. + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 49, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 99, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 0, LENGTH_UNSET)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 9, LENGTH_UNSET)).isNull(); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_unboundedRangeLocked_lockingNonOverlappingRange_succeeds() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 50, LENGTH_UNSET); + + // Non-overlapping range can be locked. + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 0, 50); + assertThat(holeSpan2.isCached).isFalse(); + assertThat(holeSpan2.position).isEqualTo(0); + assertThat(holeSpan2.length).isEqualTo(50); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_boundedRangeLocked_lockingOverlappingRange_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 50, 50); + + // Overlapping cannot be locked. + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 49, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 99, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 0, LENGTH_UNSET)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 99, LENGTH_UNSET)).isNull(); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_boundedRangeLocked_lockingNonOverlappingRange_succeeds() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 50, 50); + assertThat(holeSpan1.isCached).isFalse(); + assertThat(holeSpan1.length).isEqualTo(50); + + // Non-overlapping range can be locked. + CacheSpan holeSpan2 = simpleCache.startReadWriteNonBlocking(KEY_1, 49, 1); + assertThat(holeSpan2.isCached).isFalse(); + assertThat(holeSpan2.position).isEqualTo(49); + assertThat(holeSpan2.length).isEqualTo(1); + simpleCache.releaseHoleSpan(holeSpan2); + + CacheSpan holeSpan3 = simpleCache.startReadWriteNonBlocking(KEY_1, 100, 1); + assertThat(holeSpan3.isCached).isFalse(); + assertThat(holeSpan3.position).isEqualTo(100); + assertThat(holeSpan3.length).isEqualTo(1); + simpleCache.releaseHoleSpan(holeSpan3); + + CacheSpan holeSpan4 = simpleCache.startReadWriteNonBlocking(KEY_1, 100, LENGTH_UNSET); + assertThat(holeSpan4.isCached).isFalse(); + assertThat(holeSpan4.position).isEqualTo(100); + assertThat(holeSpan4.isOpenEnded()).isTrue(); + simpleCache.releaseHoleSpan(holeSpan4); + + simpleCache.releaseHoleSpan(holeSpan1); } @Test @@ -275,11 +395,11 @@ public class SimpleCacheTest { @Test public void removeSpans_removesSpansWithSameKey() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 10); addCache(simpleCache, KEY_1, 20, 10); simpleCache.releaseHoleSpan(holeSpan); - holeSpan = simpleCache.startReadWrite(KEY_2, 20); + holeSpan = simpleCache.startReadWrite(KEY_2, 20, LENGTH_UNSET); addCache(simpleCache, KEY_2, 20, 10); simpleCache.releaseHoleSpan(holeSpan); @@ -309,7 +429,7 @@ public class SimpleCacheTest { @Test public void getCachedLength_returnsNegativeHoleLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, /* position= */ 50, /* length= */ 50); simpleCache.releaseHoleSpan(holeSpan); @@ -330,7 +450,7 @@ public class SimpleCacheTest { @Test public void getCachedLength_returnsCachedLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 50); simpleCache.releaseHoleSpan(holeSpan); @@ -353,7 +473,7 @@ public class SimpleCacheTest { @Test public void getCachedLength_withMultipleAdjacentSpans_returnsCachedLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); simpleCache.releaseHoleSpan(holeSpan); @@ -377,7 +497,7 @@ public class SimpleCacheTest { @Test public void getCachedLength_withMultipleNonAdjacentSpans_returnsCachedLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); simpleCache.releaseHoleSpan(holeSpan); @@ -419,7 +539,7 @@ public class SimpleCacheTest { @Test public void getCachedBytes_withMultipleAdjacentSpans_returnsCachedBytes() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); simpleCache.releaseHoleSpan(holeSpan); @@ -443,7 +563,7 @@ public class SimpleCacheTest { @Test public void getCachedBytes_withMultipleNonAdjacentSpans_returnsCachedBytes() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); simpleCache.releaseHoleSpan(holeSpan); @@ -474,7 +594,7 @@ public class SimpleCacheTest { cacheDir, new LeastRecentlyUsedCacheEvictor(20), contentIndex, /* fileIndex= */ null); // Add some content. - CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); // Make index.store() throw exception from now on. @@ -502,7 +622,8 @@ public class SimpleCacheTest { SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); simpleCache.release(); assertThrows( - IllegalStateException.class, () -> simpleCache.startReadWriteNonBlocking(KEY_1, 0)); + IllegalStateException.class, + () -> simpleCache.startReadWriteNonBlocking(KEY_1, 0, LENGTH_UNSET)); } private SimpleCache getSimpleCache() { From f60d5a144f524f6fd21295d09a2c526a636aa34d Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 29 May 2020 18:30:14 +0100 Subject: [PATCH 0343/1052] Remove logging that was submitted in error PiperOrigin-RevId: 313806075 --- .../android/exoplayer2/offline/SegmentDownloader.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index a67123ca05..28ed994168 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -31,10 +31,8 @@ import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; import com.google.android.exoplayer2.upstream.cache.CacheWriter; import com.google.android.exoplayer2.upstream.cache.ContentMetadata; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; -import com.google.android.exoplayer2.util.SystemClock; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -220,23 +218,17 @@ public abstract class SegmentDownloader> impleme } } - long timer = 0; - @Override public final void remove() { Cache cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); CacheKeyFactory cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForRemovingDownload(); try { - timer = SystemClock.DEFAULT.elapsedRealtime(); M manifest = getManifest(dataSource, manifestDataSpec); - Log.e("XXX", "E1\t" + (SystemClock.DEFAULT.elapsedRealtime() - timer)); - timer = SystemClock.DEFAULT.elapsedRealtime(); List segments = getSegments(dataSource, manifest, true); for (int i = 0; i < segments.size(); i++) { cache.removeResource(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); } - Log.e("XXX", "E2\t" + (SystemClock.DEFAULT.elapsedRealtime() - timer)); } catch (IOException e) { // Ignore exceptions when removing. } finally { From dfc3c507a0fd7a9dfbc0f240c89466d0117f9b95 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 27 May 2020 20:58:42 +0100 Subject: [PATCH 0344/1052] Don't allow bad MediaSource release implementation to crash player. This also allows subsequent MediaSource instance in the list to still be released successfully. Issue: #7168 --- .../google/android/exoplayer2/ExoPlayerImplInternal.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 6fd23a93c5..108d94abb4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -954,7 +954,12 @@ import java.util.concurrent.atomic.AtomicBoolean; startPositionUs); if (releaseMediaSource) { if (mediaSource != null) { - mediaSource.releaseSource(/* caller= */ this); + try { + mediaSource.releaseSource(/* caller= */ this); + } catch (RuntimeException e) { + // There's nothing we can do. + Log.e(TAG, "Failed to release child source.", e); + } mediaSource = null; } } From 7414a86fe047a634c17a425fb86d00fa381becdb Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 27 May 2020 21:03:00 +0100 Subject: [PATCH 0345/1052] Let MediaSourceFactory setDrmSessionManager accept null Issue: #7168 --- .../com/google/android/exoplayer2/demo/PlayerActivity.java | 5 ++++- .../android/exoplayer2/source/ProgressiveMediaSource.java | 5 ++++- .../android/exoplayer2/source/dash/DashMediaSource.java | 5 ++++- .../google/android/exoplayer2/source/hls/HlsMediaSource.java | 5 ++++- .../exoplayer2/source/smoothstreaming/SsMediaSource.java | 5 ++++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 5a577449fa..0454472abf 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -628,7 +628,10 @@ public class PlayerActivity extends AppCompatActivity @Override public MediaSourceFactory setDrmSessionManager(DrmSessionManager drmSessionManager) { - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index 512fbce4a2..b48e7835ab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -174,7 +174,10 @@ public final class ProgressiveMediaSource extends BaseMediaSource @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index dcd4b15cae..39cc03dd12 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -307,7 +307,10 @@ public final class DashMediaSource extends BaseMediaSource { @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index 16dedb6c21..cc2fe618fe 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -323,7 +323,10 @@ public final class HlsMediaSource extends BaseMediaSource @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 8cc848dfa4..89dd8039ef 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -273,7 +273,10 @@ public final class SsMediaSource extends BaseMediaSource @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } From c20b85ac60701a34081d8d7488ae434b9792aa9b Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 30 Apr 2020 16:40:06 +0100 Subject: [PATCH 0346/1052] Update Gradle plugins. PiperOrigin-RevId: 309231983 --- build.gradle | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index a4823b94ee..d520925fb0 100644 --- a/build.gradle +++ b/build.gradle @@ -17,9 +17,9 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.1' + classpath 'com.android.tools.build:gradle:3.6.3' classpath 'com.novoda:bintray-release:0.9.1' - classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.0' + classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.1' } } allprojects { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7fefd1c665..dc65d6734f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip From 2fa2fb73b771512e6c15bec3bfcf1c98b654ed9f Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 1 May 2020 13:17:27 +0100 Subject: [PATCH 0347/1052] Catch correct exception from Context.startService Issue: #7306 PiperOrigin-RevId: 309392633 --- RELEASENOTES.md | 3 ++- .../com/google/android/exoplayer2/offline/DownloadService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 95e2a0581f..ba6d416506 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -17,11 +17,12 @@ EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). -* Text +* Text * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles ([#6950](https://github.com/google/ExoPlayer/pull/6950)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. +* DownloadService: Fix "Not allowed to start service" `IllegalStateException`. ### 2.11.4 (2020-04-08) ### diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 819478b80e..f78e9bb545 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -1022,7 +1022,7 @@ public abstract class DownloadService extends Service { try { Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); context.startService(intent); - } catch (IllegalArgumentException e) { + } catch (IllegalStateException e) { // The process is classed as idle by the platform. Starting a background service is not // allowed in this state. Log.w(TAG, "Failed to restart DownloadService (process is idle)."); From 01ff17f3e7da922cc746203f5b793a82fd7bb407 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 1 May 2020 19:50:46 +0100 Subject: [PATCH 0348/1052] Merge pull request #7304 from AChep:patch-1 PiperOrigin-RevId: 309395364 --- .../com/google/android/exoplayer2/ui/DefaultTimeBar.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 89bcaf84bc..8c20d441b2 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -195,7 +195,6 @@ public class DefaultTimeBar extends View implements TimeBar { private final Formatter formatter; private final Runnable stopScrubbingRunnable; private final CopyOnWriteArraySet listeners; - private final int[] locationOnScreen; private final Point touchPosition; private final float density; @@ -249,7 +248,6 @@ public class DefaultTimeBar extends View implements TimeBar { scrubberPaint = new Paint(); scrubberPaint.setAntiAlias(true); listeners = new CopyOnWriteArraySet<>(); - locationOnScreen = new int[2]; touchPosition = new Point(); // Calculate the dimensions and paints for drawn elements. @@ -755,10 +753,7 @@ public class DefaultTimeBar extends View implements TimeBar { } private Point resolveRelativeTouchPosition(MotionEvent motionEvent) { - getLocationOnScreen(locationOnScreen); - touchPosition.set( - ((int) motionEvent.getRawX()) - locationOnScreen[0], - ((int) motionEvent.getRawY()) - locationOnScreen[1]); + touchPosition.set((int) motionEvent.getX(), (int) motionEvent.getY()); return touchPosition; } From d1ff9846704c5d756aa347bdf310f98c50d9ba51 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 4 May 2020 11:14:52 +0100 Subject: [PATCH 0349/1052] CronetDataSource: Use standard InterruptedIOException PiperOrigin-RevId: 309710359 --- .../ext/cronet/CronetDataSource.java | 15 +++-------- .../ext/cronet/CronetDataSourceTest.java | 25 ++++++++++--------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java index 1903e33995..a70de17939 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -32,6 +32,7 @@ import com.google.android.exoplayer2.util.ConditionVariable; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Predicate; import java.io.IOException; +import java.io.InterruptedIOException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.ByteBuffer; @@ -83,14 +84,6 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } - /** Thrown on catching an InterruptedException. */ - public static final class InterruptedIOException extends IOException { - - public InterruptedIOException(InterruptedException e) { - super(e); - } - } - static { ExoPlayerLibraryInfo.registerModule("goog.exo.cronet"); } @@ -440,7 +433,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new OpenException(new InterruptedIOException(e), dataSpec, Status.INVALID); + throw new OpenException(new InterruptedIOException(), dataSpec, Status.INVALID); } // Check for a valid response code. @@ -705,7 +698,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) { throw new IOException("HTTP request with non-empty body must set Content-Type"); } - + // Set the Range header. if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { StringBuilder rangeValue = new StringBuilder(); @@ -769,7 +762,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } Thread.currentThread().interrupt(); throw new HttpDataSourceException( - new InterruptedIOException(e), + new InterruptedIOException(), castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ); } catch (SocketTimeoutException e) { diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java index 47f6fa7d2f..228a51f7f4 100644 --- a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java +++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -40,6 +40,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.io.InterruptedIOException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.ByteBuffer; @@ -282,7 +283,7 @@ public final class CronetDataSourceTest { fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { // Check for connection not automatically closed. - assertThat(e.getCause() instanceof UnknownHostException).isFalse(); + assertThat(e).hasCauseThat().isNotInstanceOf(UnknownHostException.class); verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); @@ -320,7 +321,7 @@ public final class CronetDataSourceTest { fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { // Check for connection not automatically closed. - assertThat(e.getCause() instanceof UnknownHostException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(UnknownHostException.class); verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); @@ -336,7 +337,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.open(testDataSpec); fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { - assertThat(e instanceof HttpDataSource.InvalidResponseCodeException).isTrue(); + assertThat(e).isInstanceOf(HttpDataSource.InvalidResponseCodeException.class); // Check for connection not automatically closed. verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) @@ -359,7 +360,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.open(testDataSpec); fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { - assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue(); + assertThat(e).isInstanceOf(HttpDataSource.InvalidContentTypeException.class); // Check for connection not automatically closed. verify(mockUrlRequest, never()).cancel(); assertThat(testedContentTypes).hasSize(1); @@ -890,8 +891,8 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) .isEqualTo(TEST_CONNECTION_STATUS); timedOutLatch.countDown(); @@ -928,8 +929,8 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) .isEqualTo(TEST_INVALID_CONNECTION_STATUS); timedOutLatch.countDown(); @@ -999,8 +1000,8 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); openExceptions.getAndIncrement(); timedOutLatch.countDown(); } @@ -1224,7 +1225,7 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); timedOutLatch.countDown(); } } @@ -1255,7 +1256,7 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); timedOutLatch.countDown(); } } From d159f622c2deb98ea78b4256d9053683641cd868 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 4 May 2020 12:48:44 +0100 Subject: [PATCH 0350/1052] Update initial bitrate estimates. PiperOrigin-RevId: 309720018 --- .../upstream/DefaultBandwidthMeter.java | 365 +++++++++--------- 1 file changed, 183 insertions(+), 182 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index 2fbed96a85..44ade5ea4f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -56,19 +56,19 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList /** Default initial Wifi bitrate estimate in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI = - new long[] {5_700_000, 3_500_000, 2_000_000, 1_100_000, 470_000}; + new long[] {5_800_000, 3_500_000, 1_900_000, 1_000_000, 520_000}; /** Default initial 2G bitrate estimates in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_2G = - new long[] {200_000, 148_000, 132_000, 115_000, 95_000}; + new long[] {204_000, 154_000, 139_000, 122_000, 102_000}; /** Default initial 3G bitrate estimates in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_3G = - new long[] {2_200_000, 1_300_000, 970_000, 810_000, 490_000}; + new long[] {2_200_000, 1_150_000, 810_000, 640_000, 450_000}; /** Default initial 4G bitrate estimates in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_4G = - new long[] {5_300_000, 3_200_000, 2_000_000, 1_400_000, 690_000}; + new long[] {4_900_000, 2_300_000, 1_500_000, 970_000, 540_000}; /** * Default initial bitrate estimate used when the device is offline or the network type cannot be @@ -488,244 +488,245 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList private static Map createInitialBitrateCountryGroupAssignment() { HashMap countryGroupAssignment = new HashMap<>(); - countryGroupAssignment.put("AD", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("AE", new int[] {1, 4, 4, 4}); + countryGroupAssignment.put("AD", new int[] {0, 2, 0, 0}); + countryGroupAssignment.put("AE", new int[] {2, 4, 4, 4}); countryGroupAssignment.put("AF", new int[] {4, 4, 3, 3}); - countryGroupAssignment.put("AG", new int[] {3, 1, 0, 1}); - countryGroupAssignment.put("AI", new int[] {1, 0, 0, 3}); + countryGroupAssignment.put("AG", new int[] {4, 2, 2, 3}); + countryGroupAssignment.put("AI", new int[] {0, 3, 2, 4}); countryGroupAssignment.put("AL", new int[] {1, 2, 0, 1}); - countryGroupAssignment.put("AM", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("AO", new int[] {3, 4, 2, 0}); - countryGroupAssignment.put("AR", new int[] {2, 3, 2, 2}); - countryGroupAssignment.put("AS", new int[] {3, 0, 4, 2}); + countryGroupAssignment.put("AM", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("AO", new int[] {3, 4, 3, 1}); + countryGroupAssignment.put("AQ", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("AR", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("AS", new int[] {2, 2, 4, 2}); countryGroupAssignment.put("AT", new int[] {0, 3, 0, 0}); - countryGroupAssignment.put("AU", new int[] {0, 3, 0, 1}); - countryGroupAssignment.put("AW", new int[] {1, 1, 0, 3}); - countryGroupAssignment.put("AX", new int[] {0, 3, 0, 2}); + countryGroupAssignment.put("AU", new int[] {0, 2, 0, 1}); + countryGroupAssignment.put("AW", new int[] {1, 1, 2, 4}); + countryGroupAssignment.put("AX", new int[] {0, 1, 0, 0}); countryGroupAssignment.put("AZ", new int[] {3, 3, 3, 3}); countryGroupAssignment.put("BA", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("BB", new int[] {0, 2, 0, 0}); - countryGroupAssignment.put("BD", new int[] {2, 1, 3, 3}); - countryGroupAssignment.put("BE", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("BB", new int[] {0, 3, 0, 0}); + countryGroupAssignment.put("BD", new int[] {2, 0, 4, 3}); + countryGroupAssignment.put("BE", new int[] {0, 1, 2, 3}); countryGroupAssignment.put("BF", new int[] {4, 4, 4, 1}); countryGroupAssignment.put("BG", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("BH", new int[] {2, 1, 3, 4}); + countryGroupAssignment.put("BH", new int[] {1, 0, 3, 4}); countryGroupAssignment.put("BI", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("BJ", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("BL", new int[] {1, 0, 2, 2}); - countryGroupAssignment.put("BM", new int[] {1, 2, 0, 0}); - countryGroupAssignment.put("BN", new int[] {4, 1, 3, 2}); - countryGroupAssignment.put("BO", new int[] {1, 2, 3, 2}); - countryGroupAssignment.put("BQ", new int[] {1, 1, 2, 4}); - countryGroupAssignment.put("BR", new int[] {2, 3, 3, 2}); - countryGroupAssignment.put("BS", new int[] {2, 1, 1, 4}); + countryGroupAssignment.put("BJ", new int[] {4, 4, 3, 4}); + countryGroupAssignment.put("BL", new int[] {1, 0, 4, 3}); + countryGroupAssignment.put("BM", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("BN", new int[] {4, 0, 2, 4}); + countryGroupAssignment.put("BO", new int[] {1, 3, 3, 3}); + countryGroupAssignment.put("BQ", new int[] {1, 0, 1, 0}); + countryGroupAssignment.put("BR", new int[] {2, 4, 3, 1}); + countryGroupAssignment.put("BS", new int[] {3, 1, 1, 3}); countryGroupAssignment.put("BT", new int[] {3, 0, 3, 1}); - countryGroupAssignment.put("BW", new int[] {4, 4, 1, 2}); - countryGroupAssignment.put("BY", new int[] {0, 1, 1, 2}); - countryGroupAssignment.put("BZ", new int[] {2, 2, 2, 1}); - countryGroupAssignment.put("CA", new int[] {0, 3, 1, 3}); - countryGroupAssignment.put("CD", new int[] {4, 4, 2, 2}); - countryGroupAssignment.put("CF", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("CG", new int[] {3, 4, 2, 4}); - countryGroupAssignment.put("CH", new int[] {0, 0, 1, 0}); + countryGroupAssignment.put("BW", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("BY", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("BZ", new int[] {1, 3, 2, 1}); + countryGroupAssignment.put("CA", new int[] {0, 3, 2, 2}); + countryGroupAssignment.put("CD", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("CF", new int[] {4, 3, 2, 2}); + countryGroupAssignment.put("CG", new int[] {3, 4, 1, 1}); + countryGroupAssignment.put("CH", new int[] {0, 0, 0, 0}); countryGroupAssignment.put("CI", new int[] {3, 4, 3, 3}); - countryGroupAssignment.put("CK", new int[] {2, 4, 1, 0}); + countryGroupAssignment.put("CK", new int[] {2, 0, 1, 0}); countryGroupAssignment.put("CL", new int[] {1, 2, 2, 3}); - countryGroupAssignment.put("CM", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("CN", new int[] {2, 0, 2, 3}); - countryGroupAssignment.put("CO", new int[] {2, 3, 2, 2}); - countryGroupAssignment.put("CR", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("CU", new int[] {4, 4, 3, 1}); - countryGroupAssignment.put("CV", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("CM", new int[] {3, 4, 3, 2}); + countryGroupAssignment.put("CN", new int[] {1, 0, 1, 1}); + countryGroupAssignment.put("CO", new int[] {2, 3, 3, 2}); + countryGroupAssignment.put("CR", new int[] {2, 2, 4, 4}); + countryGroupAssignment.put("CU", new int[] {4, 4, 2, 1}); + countryGroupAssignment.put("CV", new int[] {2, 3, 3, 2}); countryGroupAssignment.put("CW", new int[] {1, 1, 0, 0}); countryGroupAssignment.put("CY", new int[] {1, 1, 0, 0}); countryGroupAssignment.put("CZ", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("DE", new int[] {0, 1, 1, 3}); - countryGroupAssignment.put("DJ", new int[] {4, 3, 4, 1}); - countryGroupAssignment.put("DK", new int[] {0, 0, 1, 1}); - countryGroupAssignment.put("DM", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("DE", new int[] {0, 1, 2, 3}); + countryGroupAssignment.put("DJ", new int[] {4, 2, 4, 4}); + countryGroupAssignment.put("DK", new int[] {0, 0, 1, 0}); + countryGroupAssignment.put("DM", new int[] {1, 1, 0, 2}); countryGroupAssignment.put("DO", new int[] {3, 3, 4, 4}); countryGroupAssignment.put("DZ", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("EC", new int[] {2, 3, 4, 3}); - countryGroupAssignment.put("EE", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("EG", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("EH", new int[] {2, 0, 3, 3}); - countryGroupAssignment.put("ER", new int[] {4, 2, 2, 0}); + countryGroupAssignment.put("EC", new int[] {2, 3, 4, 2}); + countryGroupAssignment.put("EE", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("EG", new int[] {3, 4, 2, 1}); + countryGroupAssignment.put("EH", new int[] {2, 0, 3, 1}); + countryGroupAssignment.put("ER", new int[] {4, 2, 4, 4}); countryGroupAssignment.put("ES", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("ET", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("ET", new int[] {4, 4, 4, 1}); countryGroupAssignment.put("FI", new int[] {0, 0, 1, 0}); - countryGroupAssignment.put("FJ", new int[] {3, 0, 3, 3}); - countryGroupAssignment.put("FK", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("FM", new int[] {4, 0, 4, 0}); - countryGroupAssignment.put("FO", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("FR", new int[] {1, 0, 3, 1}); - countryGroupAssignment.put("GA", new int[] {3, 3, 2, 2}); - countryGroupAssignment.put("GB", new int[] {0, 1, 3, 3}); - countryGroupAssignment.put("GD", new int[] {2, 0, 4, 4}); - countryGroupAssignment.put("GE", new int[] {1, 1, 1, 4}); - countryGroupAssignment.put("GF", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("GG", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("GH", new int[] {3, 3, 2, 2}); - countryGroupAssignment.put("GI", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("GL", new int[] {2, 2, 0, 2}); - countryGroupAssignment.put("GM", new int[] {4, 4, 3, 4}); + countryGroupAssignment.put("FJ", new int[] {3, 0, 4, 4}); + countryGroupAssignment.put("FK", new int[] {2, 2, 2, 1}); + countryGroupAssignment.put("FM", new int[] {3, 2, 4, 1}); + countryGroupAssignment.put("FO", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("FR", new int[] {1, 1, 1, 1}); + countryGroupAssignment.put("GA", new int[] {3, 2, 2, 2}); + countryGroupAssignment.put("GB", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("GD", new int[] {1, 1, 3, 1}); + countryGroupAssignment.put("GE", new int[] {1, 0, 1, 4}); + countryGroupAssignment.put("GF", new int[] {2, 0, 1, 3}); + countryGroupAssignment.put("GG", new int[] {1, 0, 0, 0}); + countryGroupAssignment.put("GH", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("GI", new int[] {4, 4, 0, 0}); + countryGroupAssignment.put("GL", new int[] {2, 1, 1, 2}); + countryGroupAssignment.put("GM", new int[] {4, 3, 2, 4}); countryGroupAssignment.put("GN", new int[] {3, 4, 4, 2}); - countryGroupAssignment.put("GP", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("GQ", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("GR", new int[] {1, 1, 0, 2}); - countryGroupAssignment.put("GT", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("GU", new int[] {1, 2, 4, 4}); - countryGroupAssignment.put("GW", new int[] {4, 4, 4, 1}); + countryGroupAssignment.put("GP", new int[] {2, 1, 3, 4}); + countryGroupAssignment.put("GQ", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("GR", new int[] {1, 1, 0, 1}); + countryGroupAssignment.put("GT", new int[] {3, 2, 2, 2}); + countryGroupAssignment.put("GU", new int[] {1, 0, 2, 2}); + countryGroupAssignment.put("GW", new int[] {3, 4, 4, 3}); countryGroupAssignment.put("GY", new int[] {3, 2, 1, 1}); countryGroupAssignment.put("HK", new int[] {0, 2, 3, 4}); - countryGroupAssignment.put("HN", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("HN", new int[] {3, 1, 3, 3}); countryGroupAssignment.put("HR", new int[] {1, 1, 0, 1}); countryGroupAssignment.put("HT", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("HU", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("ID", new int[] {3, 2, 3, 4}); + countryGroupAssignment.put("ID", new int[] {2, 2, 2, 3}); countryGroupAssignment.put("IE", new int[] {1, 0, 1, 1}); - countryGroupAssignment.put("IL", new int[] {0, 0, 2, 3}); + countryGroupAssignment.put("IL", new int[] {1, 0, 2, 3}); countryGroupAssignment.put("IM", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("IN", new int[] {2, 2, 4, 4}); - countryGroupAssignment.put("IO", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("IN", new int[] {2, 2, 4, 3}); + countryGroupAssignment.put("IO", new int[] {4, 4, 2, 3}); countryGroupAssignment.put("IQ", new int[] {3, 3, 4, 2}); - countryGroupAssignment.put("IR", new int[] {3, 0, 2, 2}); + countryGroupAssignment.put("IR", new int[] {3, 0, 2, 1}); countryGroupAssignment.put("IS", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("IT", new int[] {1, 0, 1, 2}); + countryGroupAssignment.put("IT", new int[] {1, 1, 1, 2}); countryGroupAssignment.put("JE", new int[] {1, 0, 0, 1}); - countryGroupAssignment.put("JM", new int[] {2, 3, 3, 1}); - countryGroupAssignment.put("JO", new int[] {1, 2, 1, 2}); - countryGroupAssignment.put("JP", new int[] {0, 2, 1, 1}); - countryGroupAssignment.put("KE", new int[] {3, 4, 4, 3}); - countryGroupAssignment.put("KG", new int[] {1, 1, 2, 2}); - countryGroupAssignment.put("KH", new int[] {1, 0, 4, 4}); - countryGroupAssignment.put("KI", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("KM", new int[] {4, 3, 2, 3}); - countryGroupAssignment.put("KN", new int[] {1, 0, 1, 3}); - countryGroupAssignment.put("KP", new int[] {4, 2, 4, 2}); - countryGroupAssignment.put("KR", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("KW", new int[] {2, 3, 1, 1}); - countryGroupAssignment.put("KY", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("KZ", new int[] {1, 2, 2, 3}); + countryGroupAssignment.put("JM", new int[] {3, 3, 3, 4}); + countryGroupAssignment.put("JO", new int[] {1, 2, 1, 1}); + countryGroupAssignment.put("JP", new int[] {0, 2, 0, 0}); + countryGroupAssignment.put("KE", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("KG", new int[] {2, 0, 2, 2}); + countryGroupAssignment.put("KH", new int[] {1, 0, 4, 3}); + countryGroupAssignment.put("KI", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("KM", new int[] {4, 3, 2, 4}); + countryGroupAssignment.put("KN", new int[] {1, 0, 2, 4}); + countryGroupAssignment.put("KP", new int[] {4, 2, 0, 2}); + countryGroupAssignment.put("KR", new int[] {0, 1, 0, 1}); + countryGroupAssignment.put("KW", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("KY", new int[] {3, 1, 2, 3}); + countryGroupAssignment.put("KZ", new int[] {1, 2, 2, 2}); countryGroupAssignment.put("LA", new int[] {2, 2, 1, 1}); countryGroupAssignment.put("LB", new int[] {3, 2, 0, 0}); countryGroupAssignment.put("LC", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("LI", new int[] {0, 0, 2, 4}); - countryGroupAssignment.put("LK", new int[] {2, 1, 2, 3}); - countryGroupAssignment.put("LR", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("LS", new int[] {3, 3, 2, 0}); + countryGroupAssignment.put("LI", new int[] {0, 0, 1, 1}); + countryGroupAssignment.put("LK", new int[] {2, 0, 2, 3}); + countryGroupAssignment.put("LR", new int[] {3, 4, 4, 2}); + countryGroupAssignment.put("LS", new int[] {3, 3, 2, 2}); countryGroupAssignment.put("LT", new int[] {0, 0, 0, 0}); countryGroupAssignment.put("LU", new int[] {0, 0, 0, 0}); countryGroupAssignment.put("LV", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("LY", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("MA", new int[] {2, 1, 2, 1}); - countryGroupAssignment.put("MC", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("LY", new int[] {3, 3, 4, 3}); + countryGroupAssignment.put("MA", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("MC", new int[] {0, 4, 0, 0}); countryGroupAssignment.put("MD", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("ME", new int[] {1, 2, 1, 2}); - countryGroupAssignment.put("MF", new int[] {1, 1, 1, 1}); - countryGroupAssignment.put("MG", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("ME", new int[] {1, 3, 1, 2}); + countryGroupAssignment.put("MF", new int[] {2, 3, 1, 1}); + countryGroupAssignment.put("MG", new int[] {3, 4, 2, 3}); countryGroupAssignment.put("MH", new int[] {4, 0, 2, 4}); countryGroupAssignment.put("MK", new int[] {1, 0, 0, 0}); countryGroupAssignment.put("ML", new int[] {4, 4, 2, 0}); - countryGroupAssignment.put("MM", new int[] {3, 3, 1, 2}); - countryGroupAssignment.put("MN", new int[] {2, 3, 2, 3}); + countryGroupAssignment.put("MM", new int[] {3, 3, 2, 2}); + countryGroupAssignment.put("MN", new int[] {2, 3, 1, 1}); countryGroupAssignment.put("MO", new int[] {0, 0, 4, 4}); - countryGroupAssignment.put("MP", new int[] {0, 2, 4, 4}); - countryGroupAssignment.put("MQ", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("MR", new int[] {4, 2, 4, 2}); - countryGroupAssignment.put("MS", new int[] {1, 2, 3, 3}); - countryGroupAssignment.put("MT", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("MU", new int[] {2, 2, 3, 4}); - countryGroupAssignment.put("MV", new int[] {4, 3, 0, 2}); - countryGroupAssignment.put("MW", new int[] {3, 2, 1, 0}); - countryGroupAssignment.put("MX", new int[] {2, 4, 4, 3}); - countryGroupAssignment.put("MY", new int[] {2, 2, 3, 3}); - countryGroupAssignment.put("MZ", new int[] {3, 3, 2, 1}); - countryGroupAssignment.put("NA", new int[] {3, 3, 2, 1}); - countryGroupAssignment.put("NC", new int[] {2, 0, 3, 3}); - countryGroupAssignment.put("NE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("NF", new int[] {1, 2, 2, 2}); - countryGroupAssignment.put("NG", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("NI", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("NL", new int[] {0, 2, 3, 3}); - countryGroupAssignment.put("NO", new int[] {0, 1, 1, 0}); + countryGroupAssignment.put("MP", new int[] {0, 2, 1, 2}); + countryGroupAssignment.put("MQ", new int[] {2, 1, 1, 3}); + countryGroupAssignment.put("MR", new int[] {4, 2, 4, 4}); + countryGroupAssignment.put("MS", new int[] {1, 4, 3, 4}); + countryGroupAssignment.put("MT", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("MU", new int[] {2, 2, 4, 4}); + countryGroupAssignment.put("MV", new int[] {4, 3, 2, 4}); + countryGroupAssignment.put("MW", new int[] {3, 1, 1, 1}); + countryGroupAssignment.put("MX", new int[] {2, 4, 3, 3}); + countryGroupAssignment.put("MY", new int[] {2, 1, 3, 3}); + countryGroupAssignment.put("MZ", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("NA", new int[] {4, 3, 3, 3}); + countryGroupAssignment.put("NC", new int[] {2, 0, 4, 4}); + countryGroupAssignment.put("NE", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("NF", new int[] {1, 2, 2, 0}); + countryGroupAssignment.put("NG", new int[] {3, 3, 2, 2}); + countryGroupAssignment.put("NI", new int[] {3, 2, 4, 3}); + countryGroupAssignment.put("NL", new int[] {0, 2, 3, 2}); + countryGroupAssignment.put("NO", new int[] {0, 2, 1, 0}); countryGroupAssignment.put("NP", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("NR", new int[] {4, 0, 3, 1}); + countryGroupAssignment.put("NR", new int[] {4, 0, 3, 2}); countryGroupAssignment.put("NZ", new int[] {0, 0, 1, 2}); - countryGroupAssignment.put("OM", new int[] {3, 2, 1, 3}); - countryGroupAssignment.put("PA", new int[] {1, 3, 3, 4}); - countryGroupAssignment.put("PE", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("PF", new int[] {2, 2, 0, 1}); - countryGroupAssignment.put("PG", new int[] {4, 3, 3, 1}); + countryGroupAssignment.put("OM", new int[] {2, 3, 0, 2}); + countryGroupAssignment.put("PA", new int[] {1, 3, 3, 3}); + countryGroupAssignment.put("PE", new int[] {2, 4, 4, 4}); + countryGroupAssignment.put("PF", new int[] {2, 1, 1, 1}); + countryGroupAssignment.put("PG", new int[] {4, 3, 3, 2}); countryGroupAssignment.put("PH", new int[] {3, 0, 3, 4}); - countryGroupAssignment.put("PK", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("PL", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("PK", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("PL", new int[] {1, 0, 1, 2}); countryGroupAssignment.put("PM", new int[] {0, 2, 2, 0}); - countryGroupAssignment.put("PR", new int[] {1, 2, 3, 3}); - countryGroupAssignment.put("PS", new int[] {3, 3, 2, 4}); + countryGroupAssignment.put("PR", new int[] {2, 2, 2, 2}); + countryGroupAssignment.put("PS", new int[] {3, 3, 1, 4}); countryGroupAssignment.put("PT", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("PW", new int[] {2, 1, 2, 0}); - countryGroupAssignment.put("PY", new int[] {2, 0, 2, 3}); - countryGroupAssignment.put("QA", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("PW", new int[] {1, 1, 3, 0}); + countryGroupAssignment.put("PY", new int[] {2, 0, 3, 3}); + countryGroupAssignment.put("QA", new int[] {2, 3, 1, 1}); countryGroupAssignment.put("RE", new int[] {1, 0, 2, 2}); countryGroupAssignment.put("RO", new int[] {0, 1, 1, 2}); countryGroupAssignment.put("RS", new int[] {1, 2, 0, 0}); - countryGroupAssignment.put("RU", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("RW", new int[] {4, 4, 2, 4}); + countryGroupAssignment.put("RU", new int[] {0, 1, 0, 1}); + countryGroupAssignment.put("RW", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("SA", new int[] {2, 2, 2, 1}); - countryGroupAssignment.put("SB", new int[] {4, 4, 3, 0}); + countryGroupAssignment.put("SB", new int[] {4, 4, 4, 1}); countryGroupAssignment.put("SC", new int[] {4, 2, 0, 1}); - countryGroupAssignment.put("SD", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("SD", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("SE", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("SG", new int[] {0, 2, 3, 3}); - countryGroupAssignment.put("SH", new int[] {4, 4, 2, 3}); - countryGroupAssignment.put("SI", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("SJ", new int[] {2, 0, 2, 4}); + countryGroupAssignment.put("SG", new int[] {1, 0, 3, 3}); + countryGroupAssignment.put("SH", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("SI", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("SJ", new int[] {2, 2, 2, 4}); countryGroupAssignment.put("SK", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("SL", new int[] {4, 3, 3, 3}); - countryGroupAssignment.put("SM", new int[] {0, 0, 2, 4}); - countryGroupAssignment.put("SN", new int[] {3, 4, 4, 2}); - countryGroupAssignment.put("SO", new int[] {3, 4, 4, 3}); - countryGroupAssignment.put("SR", new int[] {2, 2, 1, 0}); - countryGroupAssignment.put("SS", new int[] {4, 3, 4, 3}); - countryGroupAssignment.put("ST", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("SV", new int[] {2, 3, 3, 4}); + countryGroupAssignment.put("SL", new int[] {4, 3, 3, 1}); + countryGroupAssignment.put("SM", new int[] {0, 0, 1, 2}); + countryGroupAssignment.put("SN", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("SO", new int[] {3, 4, 3, 4}); + countryGroupAssignment.put("SR", new int[] {2, 2, 2, 1}); + countryGroupAssignment.put("SS", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("ST", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("SV", new int[] {2, 2, 4, 4}); countryGroupAssignment.put("SX", new int[] {2, 4, 1, 0}); - countryGroupAssignment.put("SY", new int[] {4, 3, 2, 1}); + countryGroupAssignment.put("SY", new int[] {4, 3, 1, 1}); countryGroupAssignment.put("SZ", new int[] {4, 4, 3, 4}); - countryGroupAssignment.put("TC", new int[] {1, 2, 1, 1}); - countryGroupAssignment.put("TD", new int[] {4, 4, 4, 2}); - countryGroupAssignment.put("TG", new int[] {3, 3, 1, 0}); - countryGroupAssignment.put("TH", new int[] {1, 3, 4, 4}); + countryGroupAssignment.put("TC", new int[] {1, 2, 1, 0}); + countryGroupAssignment.put("TD", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("TG", new int[] {3, 2, 1, 0}); + countryGroupAssignment.put("TH", new int[] {1, 3, 3, 3}); countryGroupAssignment.put("TJ", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("TL", new int[] {4, 2, 4, 4}); - countryGroupAssignment.put("TM", new int[] {4, 1, 2, 2}); - countryGroupAssignment.put("TN", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("TO", new int[] {3, 3, 3, 1}); - countryGroupAssignment.put("TR", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("TT", new int[] {1, 3, 1, 2}); - countryGroupAssignment.put("TV", new int[] {4, 2, 2, 4}); + countryGroupAssignment.put("TM", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("TN", new int[] {2, 1, 1, 1}); + countryGroupAssignment.put("TO", new int[] {4, 3, 4, 4}); + countryGroupAssignment.put("TR", new int[] {1, 2, 1, 1}); + countryGroupAssignment.put("TT", new int[] {1, 3, 2, 4}); + countryGroupAssignment.put("TV", new int[] {4, 2, 3, 4}); countryGroupAssignment.put("TW", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("TZ", new int[] {3, 3, 4, 3}); - countryGroupAssignment.put("UA", new int[] {0, 2, 1, 2}); - countryGroupAssignment.put("UG", new int[] {4, 3, 3, 2}); - countryGroupAssignment.put("US", new int[] {1, 1, 3, 3}); - countryGroupAssignment.put("UY", new int[] {2, 2, 1, 1}); - countryGroupAssignment.put("UZ", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("VA", new int[] {1, 2, 4, 2}); - countryGroupAssignment.put("VC", new int[] {2, 0, 2, 4}); - countryGroupAssignment.put("VE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("VG", new int[] {3, 0, 1, 3}); - countryGroupAssignment.put("VI", new int[] {1, 1, 4, 4}); - countryGroupAssignment.put("VN", new int[] {0, 2, 4, 4}); - countryGroupAssignment.put("VU", new int[] {4, 1, 3, 1}); - countryGroupAssignment.put("WS", new int[] {3, 3, 3, 2}); + countryGroupAssignment.put("TZ", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("UA", new int[] {0, 3, 1, 1}); + countryGroupAssignment.put("UG", new int[] {3, 2, 2, 3}); + countryGroupAssignment.put("US", new int[] {0, 1, 2, 2}); + countryGroupAssignment.put("UY", new int[] {2, 1, 2, 2}); + countryGroupAssignment.put("UZ", new int[] {2, 2, 3, 2}); + countryGroupAssignment.put("VA", new int[] {0, 2, 2, 2}); + countryGroupAssignment.put("VC", new int[] {2, 3, 0, 2}); + countryGroupAssignment.put("VE", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("VG", new int[] {3, 1, 2, 4}); + countryGroupAssignment.put("VI", new int[] {1, 4, 4, 3}); + countryGroupAssignment.put("VN", new int[] {0, 1, 3, 4}); + countryGroupAssignment.put("VU", new int[] {4, 0, 3, 3}); + countryGroupAssignment.put("WS", new int[] {3, 2, 4, 3}); countryGroupAssignment.put("XK", new int[] {1, 2, 1, 0}); countryGroupAssignment.put("YE", new int[] {4, 4, 4, 3}); countryGroupAssignment.put("YT", new int[] {2, 2, 2, 3}); - countryGroupAssignment.put("ZA", new int[] {2, 4, 2, 2}); - countryGroupAssignment.put("ZM", new int[] {3, 2, 2, 1}); - countryGroupAssignment.put("ZW", new int[] {3, 3, 2, 1}); + countryGroupAssignment.put("ZA", new int[] {2, 3, 2, 2}); + countryGroupAssignment.put("ZM", new int[] {3, 2, 3, 3}); + countryGroupAssignment.put("ZW", new int[] {3, 3, 2, 3}); return Collections.unmodifiableMap(countryGroupAssignment); } } From 2112b722b58682a43932fa9bed39dec6a40ec375 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 4 May 2020 14:54:54 +0100 Subject: [PATCH 0351/1052] Add missing @Player.State in action schedule PiperOrigin-RevId: 309735092 --- .../java/com/google/android/exoplayer2/testutil/Action.java | 4 ++-- .../google/android/exoplayer2/testutil/ActionSchedule.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index b65accdf3f..2d6beff416 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -685,13 +685,13 @@ public abstract class Action { */ public static final class WaitForPlaybackState extends Action { - private final int targetPlaybackState; + @Player.State private final int targetPlaybackState; /** * @param tag A tag to use for logging. * @param targetPlaybackState The playback state to wait for. */ - public WaitForPlaybackState(String tag, int targetPlaybackState) { + public WaitForPlaybackState(String tag, @Player.State int targetPlaybackState) { super(tag, "WaitForPlaybackState"); this.targetPlaybackState = targetPlaybackState; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java index f6ab4b9924..4800df662c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java @@ -433,7 +433,7 @@ public final class ActionSchedule { * @param targetPlaybackState The target playback state. * @return The builder, for convenience. */ - public Builder waitForPlaybackState(int targetPlaybackState) { + public Builder waitForPlaybackState(@Player.State int targetPlaybackState) { return apply(new WaitForPlaybackState(tag, targetPlaybackState)); } From 8304cf34807671e26a0456a537398258655e6f2e Mon Sep 17 00:00:00 2001 From: samrobinson Date: Tue, 5 May 2020 13:04:58 +0100 Subject: [PATCH 0352/1052] Change SilenceSkippingAudioProcessor to not rely on the frame MSB. PiperOrigin-RevId: 309925306 --- .../audio/SilenceSkippingAudioProcessor.java | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java index 2a98d2fb25..454007194f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java @@ -17,11 +17,13 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.IntDef; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; +import java.nio.ByteOrder; /** * An {@link AudioProcessor} that skips silence in the input stream. Input and output are 16-bit @@ -39,19 +41,9 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { * not exceed {@link #MINIMUM_SILENCE_DURATION_US}. */ private static final long PADDING_SILENCE_US = 20_000; - /** - * The absolute level below which an individual PCM sample is classified as silent. Note: the - * specified value will be rounded so that the threshold check only depends on the more - * significant byte, for efficiency. - */ + /** The absolute level below which an individual PCM sample is classified as silent. */ private static final short SILENCE_THRESHOLD_LEVEL = 1024; - /** - * Threshold for classifying an individual PCM sample as silent based on its more significant - * byte. This is {@link #SILENCE_THRESHOLD_LEVEL} divided by 256 with rounding. - */ - private static final byte SILENCE_THRESHOLD_LEVEL_MSB = (SILENCE_THRESHOLD_LEVEL + 128) >> 8; - /** Trimming states. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -325,9 +317,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { * classified as a noisy frame, or the limit of the buffer if no such frame exists. */ private int findNoisePosition(ByteBuffer buffer) { + Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); // The input is in ByteOrder.nativeOrder(), which is little endian on Android. - for (int i = buffer.position() + 1; i < buffer.limit(); i += 2) { - if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + for (int i = buffer.position(); i < buffer.limit(); i += 2) { + if (Math.abs(buffer.getShort(i)) > SILENCE_THRESHOLD_LEVEL) { // Round to the start of the frame. return bytesPerFrame * (i / bytesPerFrame); } @@ -340,9 +333,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { * from the byte position to the limit are classified as silent. */ private int findNoiseLimit(ByteBuffer buffer) { + Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); // The input is in ByteOrder.nativeOrder(), which is little endian on Android. - for (int i = buffer.limit() - 1; i >= buffer.position(); i -= 2) { - if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + for (int i = buffer.limit() - 2; i >= buffer.position(); i -= 2) { + if (Math.abs(buffer.getShort(i)) > SILENCE_THRESHOLD_LEVEL) { // Return the start of the next frame. return bytesPerFrame * (i / bytesPerFrame) + bytesPerFrame; } From 4862c7e6a8d259238eec96c4e49513fc386dfdca Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 6 May 2020 10:25:48 +0100 Subject: [PATCH 0353/1052] Upgrade OkHttp to 3.12.11. PiperOrigin-RevId: 310114401 --- RELEASENOTES.md | 1 + extensions/okhttp/build.gradle | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ba6d416506..2d9412aa3b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,6 +22,7 @@ ([#6950](https://github.com/google/ExoPlayer/pull/6950)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. * DownloadService: Fix "Not allowed to start service" `IllegalStateException`. ### 2.11.4 (2020-04-08) ### diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 41eac7c661..e0d12b5c1c 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -41,7 +41,7 @@ dependencies { // https://cashapp.github.io/2019-02-05/okhttp-3-13-requires-android-5 // Since OkHttp is distributed as a jar rather than an aar, Gradle won't // stop us from making this mistake! - api 'com.squareup.okhttp3:okhttp:3.12.8' + api 'com.squareup.okhttp3:okhttp:3.12.11' } ext { From 5f6a489bd1e0134c9c3cc898cc8feb6a547000a9 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 6 May 2020 20:57:06 +0100 Subject: [PATCH 0354/1052] Merge pull request #7324 from tpiDev:cronet/migrate-to-play-services-17-0-0 PiperOrigin-RevId: 310115628 --- RELEASENOTES.md | 4 ++ extensions/cronet/README.md | 44 ++++++++++++ extensions/cronet/build.gradle | 2 +- .../ext/cronet/CronetDataSourceTest.java | 67 ++++++++++++++++--- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2d9412aa3b..9ffeda9365 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -23,6 +23,10 @@ * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. * OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* Cronet extension: Default to using the Cronet implementation in Google Play + Services rather than Cronet Embedded. This allows Cronet to be used with a + negligible increase in application size, compared to approximately 8MB when + embedding the library. * DownloadService: Fix "Not allowed to start service" `IllegalStateException`. ### 2.11.4 (2020-04-08) ### diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md index dc64b862b6..112ad26bba 100644 --- a/extensions/cronet/README.md +++ b/extensions/cronet/README.md @@ -20,6 +20,10 @@ Alternatively, you can clone the ExoPlayer repository and depend on the module locally. Instructions for doing this can be found in ExoPlayer's [top level README][]. +Note that by default, the extension will use the Cronet implementation in +Google Play Services. If you prefer, it's also possible to embed the Cronet +implementation directly into your application. See below for more details. + [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md ## Using the extension ## @@ -47,6 +51,46 @@ new DefaultDataSourceFactory( ``` respectively. +## Choosing between Google Play Services Cronet and Cronet Embedded ## + +The underlying Cronet implementation is available both via a [Google Play +Services](https://developers.google.com/android/guides/overview) API, and as a +library that can be embedded directly into your application. When you depend on +`com.google.android.exoplayer:extension-cronet:2.X.X`, the library will _not_ be +embedded into your application by default. The extension will attempt to use the +Cronet implementation in Google Play Services. The benefits of this approach +are: + +* A negligible increase in the size of your application. +* The Cronet implementation is updated automatically by Google Play Services. + +If Google Play Services is not available on a device, `CronetDataSourceFactory` +will fall back to creating `DefaultHttpDataSource` instances, or +`HttpDataSource` instances created by a `fallbackFactory` that you can specify. + +It's also possible to embed the Cronet implementation directly into your +application. To do this, add an additional gradle dependency to the Cronet +Embedded library: + +```gradle +implementation 'com.google.android.exoplayer:extension-cronet:2.X.X' +implementation 'org.chromium.net:cronet-embedded:XX.XXXX.XXX' +``` + +where `XX.XXXX.XXX` is the version of the library that you wish to use. The +extension will automatically detect and use the library. Embedding will add +approximately 8MB to your application, however it may be suitable if: + +* Your application is likely to be used in markets where Google Play Services is + not widely available. +* You want to control the exact version of the Cronet implementation being used. + +If you do embed the library, you can specify which implementation should +be preferred if the Google Play Services implementation is also available. This +is controlled by a `preferGMSCoreCronet` parameter, which can be passed to the +`CronetEngineWrapper` constructor (GMS Core is another name for Google Play +Services). + ## Links ## * [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*` diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index d5b7a99f96..1c80a21ecc 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -31,7 +31,7 @@ android { } dependencies { - api 'org.chromium.net:cronet-embedded:76.3809.111' + api "com.google.android.gms:play-services-cronet:17.0.0" implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java index 228a51f7f4..093a09499d 100644 --- a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java +++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -48,6 +48,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; @@ -57,7 +58,6 @@ import org.chromium.net.CronetEngine; import org.chromium.net.NetworkException; import org.chromium.net.UrlRequest; import org.chromium.net.UrlResponseInfo; -import org.chromium.net.impl.UrlResponseInfoImpl; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -140,15 +140,62 @@ public final class CronetDataSourceTest { private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) { ArrayList> responseHeaderList = new ArrayList<>(); - responseHeaderList.addAll(testResponseHeader.entrySet()); - return new UrlResponseInfoImpl( - Collections.singletonList(url), - statusCode, - null, // httpStatusText - responseHeaderList, - false, // wasCached - null, // negotiatedProtocol - null); // proxyServer + Map> responseHeaderMap = new HashMap<>(); + for (Map.Entry entry : testResponseHeader.entrySet()) { + responseHeaderList.add(entry); + responseHeaderMap.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + return new UrlResponseInfo() { + @Override + public String getUrl() { + return url; + } + + @Override + public List getUrlChain() { + return Collections.singletonList(url); + } + + @Override + public int getHttpStatusCode() { + return statusCode; + } + + @Override + public String getHttpStatusText() { + return null; + } + + @Override + public List> getAllHeadersAsList() { + return responseHeaderList; + } + + @Override + public Map> getAllHeaders() { + return responseHeaderMap; + } + + @Override + public boolean wasCached() { + return false; + } + + @Override + public String getNegotiatedProtocol() { + return null; + } + + @Override + public String getProxyServer() { + return null; + } + + @Override + public long getReceivedByteCount() { + return 0; + } + }; } @Test From ad1dffcae8e3ff73a32716a47d0b837c7d4ffe59 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 11 May 2020 15:24:15 +0100 Subject: [PATCH 0355/1052] Make the base values of SilenceSkippingAudioProcessor configurable. Issue:#6705 PiperOrigin-RevId: 310907118 --- RELEASENOTES.md | 2 + .../exoplayer2/audio/DefaultAudioSink.java | 17 +++++- .../audio/SilenceSkippingAudioProcessor.java | 56 ++++++++++++++----- .../SilenceSkippingAudioProcessorTest.java | 29 +++++++++- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9ffeda9365..cc03d8621c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,8 @@ ### Next release ### +* Enable the configuration of `SilenceSkippingAudioProcessor` + ([#6705](https://github.com/google/ExoPlayer/issues/6705)). * Add `SilenceMediaSource.Factory` to support tags ([PR #7245](https://github.com/google/ExoPlayer/pull/7245)). * Avoid throwing an exception while parsing fragmented MP4 default sample diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index ba31c118e7..32a819bf81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -120,9 +120,20 @@ public final class DefaultAudioSink implements AudioSink { /** * Creates a new default chain of audio processors, with the user-defined {@code - * audioProcessors} applied before silence skipping and playback parameters. + * audioProcessors} applied before silence skipping and speed adjustment processors. */ public DefaultAudioProcessorChain(AudioProcessor... audioProcessors) { + this(audioProcessors, new SilenceSkippingAudioProcessor(), new SonicAudioProcessor()); + } + + /** + * Creates a new default chain of audio processors, with the user-defined {@code + * audioProcessors} applied before silence skipping and speed adjustment processors. + */ + public DefaultAudioProcessorChain( + AudioProcessor[] audioProcessors, + SilenceSkippingAudioProcessor silenceSkippingAudioProcessor, + SonicAudioProcessor sonicAudioProcessor) { // The passed-in type may be more specialized than AudioProcessor[], so allocate a new array // rather than using Arrays.copyOf. this.audioProcessors = new AudioProcessor[audioProcessors.length + 2]; @@ -132,8 +143,8 @@ public final class DefaultAudioSink implements AudioSink { /* dest= */ this.audioProcessors, /* destPos= */ 0, /* length= */ audioProcessors.length); - silenceSkippingAudioProcessor = new SilenceSkippingAudioProcessor(); - sonicAudioProcessor = new SonicAudioProcessor(); + this.silenceSkippingAudioProcessor = silenceSkippingAudioProcessor; + this.sonicAudioProcessor = sonicAudioProcessor; this.audioProcessors[audioProcessors.length] = silenceSkippingAudioProcessor; this.audioProcessors[audioProcessors.length + 1] = sonicAudioProcessor; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java index 454007194f..7ddb491525 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java @@ -32,17 +32,20 @@ import java.nio.ByteOrder; public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { /** - * The minimum duration of audio that must be below {@link #SILENCE_THRESHOLD_LEVEL} to classify - * that part of audio as silent, in microseconds. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * minimumSilenceDurationUs}. */ - private static final long MINIMUM_SILENCE_DURATION_US = 150_000; + public static final long DEFAULT_MINIMUM_SILENCE_DURATION_US = 150_000; /** - * The duration of silence by which to extend non-silent sections, in microseconds. The value must - * not exceed {@link #MINIMUM_SILENCE_DURATION_US}. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * paddingSilenceUs}. */ - private static final long PADDING_SILENCE_US = 20_000; - /** The absolute level below which an individual PCM sample is classified as silent. */ - private static final short SILENCE_THRESHOLD_LEVEL = 1024; + public static final long DEFAULT_PADDING_SILENCE_US = 20_000; + /** + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * silenceThresholdLevel}. + */ + public static final short DEFAULT_SILENCE_THRESHOLD_LEVEL = 1024; /** Trimming states. */ @Documented @@ -60,8 +63,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { /** State when the input is silent. */ private static final int STATE_SILENT = 2; + private final long minimumSilenceDurationUs; + private final long paddingSilenceUs; + private final short silenceThresholdLevel; private int bytesPerFrame; - private boolean enabled; /** @@ -83,8 +88,31 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { private boolean hasOutputNoise; private long skippedFrames; - /** Creates a new silence trimming audio processor. */ + /** Creates a new silence skipping audio processor. */ public SilenceSkippingAudioProcessor() { + this( + DEFAULT_MINIMUM_SILENCE_DURATION_US, + DEFAULT_PADDING_SILENCE_US, + DEFAULT_SILENCE_THRESHOLD_LEVEL); + } + + /** + * Creates a new silence skipping audio processor. + * + * @param minimumSilenceDurationUs The minimum duration of audio that must be below {@code + * silenceThresholdLevel} to classify that part of audio as silent, in microseconds. + * @param paddingSilenceUs The duration of silence by which to extend non-silent sections, in + * microseconds. The value must not exceed {@code minimumSilenceDurationUs}. + * @param silenceThresholdLevel The absolute level below which an individual PCM sample is + * classified as silent. + */ + public SilenceSkippingAudioProcessor( + long minimumSilenceDurationUs, long paddingSilenceUs, short silenceThresholdLevel) { + Assertions.checkArgument(paddingSilenceUs <= minimumSilenceDurationUs); + this.minimumSilenceDurationUs = minimumSilenceDurationUs; + this.paddingSilenceUs = paddingSilenceUs; + this.silenceThresholdLevel = silenceThresholdLevel; + maybeSilenceBuffer = Util.EMPTY_BYTE_ARRAY; paddingBuffer = Util.EMPTY_BYTE_ARRAY; } @@ -158,11 +186,11 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { protected void onFlush() { if (enabled) { bytesPerFrame = inputAudioFormat.bytesPerFrame; - int maybeSilenceBufferSize = durationUsToFrames(MINIMUM_SILENCE_DURATION_US) * bytesPerFrame; + int maybeSilenceBufferSize = durationUsToFrames(minimumSilenceDurationUs) * bytesPerFrame; if (maybeSilenceBuffer.length != maybeSilenceBufferSize) { maybeSilenceBuffer = new byte[maybeSilenceBufferSize]; } - paddingSize = durationUsToFrames(PADDING_SILENCE_US) * bytesPerFrame; + paddingSize = durationUsToFrames(paddingSilenceUs) * bytesPerFrame; if (paddingBuffer.length != paddingSize) { paddingBuffer = new byte[paddingSize]; } @@ -320,7 +348,7 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); // The input is in ByteOrder.nativeOrder(), which is little endian on Android. for (int i = buffer.position(); i < buffer.limit(); i += 2) { - if (Math.abs(buffer.getShort(i)) > SILENCE_THRESHOLD_LEVEL) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Round to the start of the frame. return bytesPerFrame * (i / bytesPerFrame); } @@ -336,7 +364,7 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); // The input is in ByteOrder.nativeOrder(), which is little endian on Android. for (int i = buffer.limit() - 2; i >= buffer.position(); i -= 2) { - if (Math.abs(buffer.getShort(i)) > SILENCE_THRESHOLD_LEVEL) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Return the start of the next frame. return bytesPerFrame * (i / bytesPerFrame) + bytesPerFrame; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java index 6783c96055..4933460e01 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java @@ -204,7 +204,34 @@ public final class SilenceSkippingAudioProcessorTest { } @Test - public void testSkipThenFlush_resetsSkippedFrameCount() throws Exception { + public void customPaddingValue_hasCorrectOutputAndSkippedFrameCounts() throws Exception { + // Given a signal that alternates between silence and noise. + InputBufferProvider inputBufferProvider = + getInputBufferProviderForAlternatingSilenceAndNoise( + TEST_SIGNAL_SILENCE_DURATION_MS, + TEST_SIGNAL_NOISE_DURATION_MS, + TEST_SIGNAL_FRAME_COUNT); + + // When processing the entire signal with a larger than normal padding silence. + SilenceSkippingAudioProcessor silenceSkippingAudioProcessor = + new SilenceSkippingAudioProcessor( + SilenceSkippingAudioProcessor.DEFAULT_MINIMUM_SILENCE_DURATION_US, + /* paddingSilenceUs= */ 21_000, + SilenceSkippingAudioProcessor.DEFAULT_SILENCE_THRESHOLD_LEVEL); + silenceSkippingAudioProcessor.setEnabled(true); + silenceSkippingAudioProcessor.configure(AUDIO_FORMAT); + silenceSkippingAudioProcessor.flush(); + assertThat(silenceSkippingAudioProcessor.isActive()).isTrue(); + long totalOutputFrames = + process(silenceSkippingAudioProcessor, inputBufferProvider, /* inputBufferSize= */ 120); + + // The right number of frames are skipped/output. + assertThat(totalOutputFrames).isEqualTo(58379); + assertThat(silenceSkippingAudioProcessor.getSkippedFrames()).isEqualTo(41621); + } + + @Test + public void skipThenFlush_resetsSkippedFrameCount() throws Exception { // Given a signal that alternates between silence and noise. InputBufferProvider inputBufferProvider = getInputBufferProviderForAlternatingSilenceAndNoise( From f116f298125fa852c172a47b4cd29544f112d2a1 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 13 May 2020 10:06:56 +0100 Subject: [PATCH 0356/1052] Prevent leaking of the Thread.interrupt flag to other LoadTasks PiperOrigin-RevId: 311290214 --- .../android/exoplayer2/upstream/Loader.java | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java index a498f510dd..5b7846f5ce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java @@ -32,6 +32,7 @@ import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; /** * Manages the background loading of {@link Loadable}s. @@ -56,6 +57,21 @@ public final class Loader implements LoaderErrorThrower { /** * Cancels the load. + * + *

    Loadable implementations should ensure that a currently executing {@link #load()} call + * will exit reasonably quickly after this method is called. The {@link #load()} call may exit + * either by returning or by throwing an {@link IOException}. + * + *

    If there is a currently executing {@link #load()} call, then the thread on which that call + * is being made will be interrupted immediately after the call to this method. Hence + * implementations do not need to (and should not attempt to) interrupt the loading thread + * themselves. + * + *

    Although the loading thread will be interrupted, Loadable implementations should not use + * the interrupted status of the loading thread in {@link #load()} to determine whether the load + * has been canceled. This approach is not robust [Internal ref: b/79223737]. Instead, + * implementations should use their own flag to signal cancelation (for example, using {@link + * AtomicBoolean}). */ void cancelLoad(); @@ -309,10 +325,9 @@ public final class Loader implements LoaderErrorThrower { private static final String TAG = "LoadTask"; private static final int MSG_START = 0; - private static final int MSG_CANCEL = 1; - private static final int MSG_END_OF_SOURCE = 2; - private static final int MSG_IO_EXCEPTION = 3; - private static final int MSG_FATAL_ERROR = 4; + private static final int MSG_FINISH = 1; + private static final int MSG_IO_EXCEPTION = 2; + private static final int MSG_FATAL_ERROR = 3; public final int defaultMinRetryCount; @@ -323,8 +338,8 @@ public final class Loader implements LoaderErrorThrower { @Nullable private IOException currentError; private int errorCount; - @Nullable private volatile Thread executorThread; - private volatile boolean canceled; + @Nullable private Thread executorThread; + private boolean canceled; private volatile boolean released; public LoadTask(Looper looper, T loadable, Loader.Callback callback, @@ -356,16 +371,21 @@ public final class Loader implements LoaderErrorThrower { this.released = released; currentError = null; if (hasMessages(MSG_START)) { + // The task has not been given to the executor yet. + canceled = true; removeMessages(MSG_START); if (!released) { - sendEmptyMessage(MSG_CANCEL); + sendEmptyMessage(MSG_FINISH); } } else { - canceled = true; - loadable.cancelLoad(); - Thread executorThread = this.executorThread; - if (executorThread != null) { - executorThread.interrupt(); + // The task has been given to the executor. + synchronized (this) { + canceled = true; + loadable.cancelLoad(); + @Nullable Thread executorThread = this.executorThread; + if (executorThread != null) { + executorThread.interrupt(); + } } } if (released) { @@ -384,8 +404,12 @@ public final class Loader implements LoaderErrorThrower { @Override public void run() { try { - executorThread = Thread.currentThread(); - if (!canceled) { + boolean shouldLoad; + synchronized (this) { + shouldLoad = !canceled; + executorThread = Thread.currentThread(); + } + if (shouldLoad) { TraceUtil.beginSection("load:" + loadable.getClass().getSimpleName()); try { loadable.load(); @@ -393,8 +417,13 @@ public final class Loader implements LoaderErrorThrower { TraceUtil.endSection(); } } + synchronized (this) { + executorThread = null; + // Clear the interrupted flag if set, to avoid it leaking into a subsequent task. + Thread.interrupted(); + } if (!released) { - sendEmptyMessage(MSG_END_OF_SOURCE); + sendEmptyMessage(MSG_FINISH); } } catch (IOException e) { if (!released) { @@ -404,7 +433,7 @@ public final class Loader implements LoaderErrorThrower { // The load was canceled. Assertions.checkState(canceled); if (!released) { - sendEmptyMessage(MSG_END_OF_SOURCE); + sendEmptyMessage(MSG_FINISH); } } catch (Exception e) { // This should never happen, but handle it anyway. @@ -453,10 +482,7 @@ public final class Loader implements LoaderErrorThrower { return; } switch (msg.what) { - case MSG_CANCEL: - callback.onLoadCanceled(loadable, nowMs, durationMs, false); - break; - case MSG_END_OF_SOURCE: + case MSG_FINISH: try { callback.onLoadCompleted(loadable, nowMs, durationMs); } catch (RuntimeException e) { From bc96d3a93c3c834337bc9165e98cca30c596e85c Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 15 May 2020 10:58:46 +0100 Subject: [PATCH 0357/1052] Merge pull request #7367 from inv3rse:keep-paused-state-during-buffering PiperOrigin-RevId: 311623784 --- RELEASENOTES.md | 3 +++ .../exoplayer2/ext/mediasession/MediaSessionConnector.java | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cc03d8621c..4ac4648d0c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -29,6 +29,9 @@ Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when embedding the library. +* MediaSession extension: Set session playback state to BUFFERING only when + actually playing ([#7367](https://github.com/google/ExoPlayer/pull/7367), + [#7206](https://github.com/google/ExoPlayer/issues/7206)). * DownloadService: Fix "Not allowed to start service" `IllegalStateException`. ### 2.11.4 (2020-04-08) ### diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index 41a2071827..0847686d21 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -987,7 +987,9 @@ public final class MediaSessionConnector { @Player.State int exoPlayerPlaybackState, boolean playWhenReady) { switch (exoPlayerPlaybackState) { case Player.STATE_BUFFERING: - return PlaybackStateCompat.STATE_BUFFERING; + return playWhenReady + ? PlaybackStateCompat.STATE_BUFFERING + : PlaybackStateCompat.STATE_PAUSED; case Player.STATE_READY: return playWhenReady ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; case Player.STATE_ENDED: From 4736a102f875c4a8b7ac53e7c9d75fe85032d7e7 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 15 May 2020 00:27:33 +0100 Subject: [PATCH 0358/1052] Attach ExoMediaCryptoType for progressive streams PiperOrigin-RevId: 311628160 --- .../android/exoplayer2/source/ProgressiveMediaPeriod.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index efdfdf15a8..277e17410d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -730,6 +730,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackFormat = trackFormat.copyWithBitrate(icyHeaders.bitrate); } } + if (trackFormat.drmInitData != null) { + trackFormat = + trackFormat.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(trackFormat.drmInitData)); + } trackArray[i] = new TrackGroup(trackFormat); } isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; From 8736324d0e3fc348ead3b03d8d2a23e515e1c66d Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 15 May 2020 10:48:11 +0100 Subject: [PATCH 0359/1052] Clean up samples list - Add Widevine AV1 streams - Remove SD and HD only Widevine streams (we don't need so many!) - Simplify naming PiperOrigin-RevId: 311697741 --- demos/main/src/main/assets/media.exolist.json | 284 ++++++------------ 1 file changed, 96 insertions(+), 188 deletions(-) diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 4375bdf3a7..ac5737d195 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -3,208 +3,147 @@ "name": "YouTube DASH", "samples": [ { - "name": "Google Glass (MP4,H264)", + "name": "Google Glass H264 (MP4)", "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0", "extension": "mpd" }, { - "name": "Google Play (MP4,H264)", + "name": "Google Play H264 (MP4)", "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=A2716F75795F5D2AF0E88962FFCD10DB79384F29.84308FF04844498CE6FBCE4731507882B8307798&key=ik0", "extension": "mpd" }, { - "name": "Google Glass (WebM,VP9)", + "name": "Google Glass VP9 (WebM)", "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=249B04F79E984D7F86B4D8DB48AE6FAF41C17AB3.7B9F0EC0505E1566E59B8E488E9419F253DDF413&key=ik0", "extension": "mpd" }, { - "name": "Google Play (WebM,VP9)", + "name": "Google Play VP9 (WebM)", "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=B1C2A74783AC1CC4865EB312D7DD2D48230CC9FD.BD153B9882175F1F94BFE5141A5482313EA38E8D&key=ik0", "extension": "mpd" } ] }, { - "name": "Widevine DASH Policy Tests (GTS)", + "name": "Widevine GTS policy tests", "samples": [ { - "name": "WV: HDCP not specified", + "name": "SW secure crypto (L3)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=d286538032258a1c&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: HDCP not required", + "name": "SW secure decode", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=48fcc369939ac96c&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_DECODE&provider=widevine_test" }, { - "name": "WV: HDCP required", + "name": "HW secure crypto", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=e06c39f1151da3df&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: Secure video path required (MP4,H264)", + "name": "HW secure decode", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_DECODE&provider=widevine_test" }, { - "name": "WV: Secure video path required (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" - }, - { - "name": "WV: Secure video path required (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" - }, - { - "name": "WV: HDCP + secure video path required", + "name": "HW secure all (L1)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=efd045b1eb61888a&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" }, { - "name": "WV: 30s license duration (fails at ~30s)", + "name": "30s license (fails at ~30s)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=f9a34cab7b05881a&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_CAN_RENEW_FALSE_LICENSE_30S_PLAYBACK_30S&provider=widevine_test" + }, + { + "name": "HDCP not required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NONE&provider=widevine_test" + }, + { + "name": "HDCP 1.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V1&provider=widevine_test" + }, + { + "name": "HDCP 2.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2&provider=widevine_test" + }, + { + "name": "HDCP 2.1 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_1&provider=widevine_test" + }, + { + "name": "HDCP 2.2 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_2&provider=widevine_test" + }, + { + "name": "HDCP no digital output", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NO_DIGITAL_OUTPUT&provider=widevine_test" } ] }, { - "name": "Widevine HDCP Capabilities Tests", + "name": "Widevine DASH H264 (MP4)", "samples": [ { - "name": "WV: HDCP: None (not required)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_None&provider=widevine_test" - }, - { - "name": "WV: HDCP: 1.0 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V1&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.0 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.1 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2_1&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.2 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2_2&provider=widevine_test" - }, - { - "name": "WV: HDCP: No digital output", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_NO_DIGTAL_OUTPUT&provider=widevine_test" - } - ] - }, - { - "name": "Widevine DASH: MP4,H264", - "samples": [ - { - "name": "WV: Clear SD & HD (MP4,H264)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd" }, { - "name": "WV: Clear SD (MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (MP4,H264)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_uhd.mpd" }, { - "name": "WV: Secure SD & HD (cenc,MP4,H264)", + "name": "Secure (cenc)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cenc,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cenc,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cenc,MP4,H264)", + "name": "Secure UHD (cenc)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD & HD (cbc1,MP4,H264)", + "name": "Secure (cbc1)", "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cbc1,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cbc1,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cbc1,MP4,H264)", + "name": "Secure UHD (cbc1)", "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD & HD (cbcs,MP4,H264)", + "name": "Secure (cbcs)", "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cbcs,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cbcs,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cbcs,MP4,H264)", + "name": "Secure UHD (cbcs)", "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" @@ -212,68 +151,36 @@ ] }, { - "name": "Widevine DASH: WebM,VP9", + "name": "Widevine DASH VP9 (WebM)", "samples": [ { - "name": "WV: Clear SD & HD (WebM,VP9)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears.mpd" }, { - "name": "WV: Clear SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (WebM,VP9)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_uhd.mpd" }, { - "name": "WV: Secure Fullsample SD & HD (WebM,VP9)", + "name": "Secure (full-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Fullsample SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Fullsample HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Fullsample UHD (WebM,VP9)", + "name": "Secure UHD (full-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Subsample SD & HD (WebM,VP9)", + "name": "Secure (sub-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Subsample SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample UHD (WebM,VP9)", + "name": "Secure UHD (sub-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" @@ -281,50 +188,51 @@ ] }, { - "name": "Widevine DASH: MP4,H265", + "name": "Widevine DASH H265 (MP4)", "samples": [ { - "name": "WV: Clear SD & HD (MP4,H265)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears.mpd" }, { - "name": "WV: Clear SD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (MP4,H265)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_uhd.mpd" }, { - "name": "WV: Secure SD & HD (MP4,H265)", + "name": "Secure", "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (MP4,H265)", + "name": "Secure UHD", "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" } ] }, + { + "name": "Widevine AV1 (WebM)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/2019/clear/av1/24/webm/llama_av1_480p_400.webm" + }, + { + "name": "Secure L3", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" + }, + { + "name": "Secure L1", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" + } + ] + }, { "name": "SmoothStreaming", "samples": [ @@ -355,7 +263,7 @@ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8" }, { - "name": "Apple master playlist advanced (fMP4)", + "name": "Apple master playlist advanced (FMP4)", "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8" }, { From 09025d3912cefbe13f8e2dbf7017a4a57a7c548e Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 18 May 2020 10:32:23 +0100 Subject: [PATCH 0360/1052] Allow MP3 files to play with size greater than 2GB. Issue:#7337 PiperOrigin-RevId: 312042768 --- RELEASENOTES.md | 2 ++ .../com/google/android/exoplayer2/extractor/mp3/XingSeeker.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4ac4648d0c..4126bdb5e0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -17,6 +17,8 @@ marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * Fix assertion failure in `SampleQueue` when playing DASH streams with EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). +* MP3: Allow MP3 files with XING headers that are larger than 2GB to be played + ([#7337](https://github.com/google/ExoPlayer/issues/7337)). * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). * Text diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java index db1a0199ac..c51b68a7c6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java @@ -60,7 +60,7 @@ import com.google.android.exoplayer2.util.Util; return new XingSeeker(position, mpegAudioHeader.frameSize, durationUs); } - long dataSize = frame.readUnsignedIntToInt(); + long dataSize = frame.readUnsignedInt(); long[] tableOfContents = new long[100]; for (int i = 0; i < 100; i++) { tableOfContents[i] = frame.readUnsignedByte(); From 1d8dd763f0bd3f0f54707860129d125908ce35a6 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 19 May 2020 14:19:56 +0100 Subject: [PATCH 0361/1052] Fix SimpleCache.getCachedLength rollover bug & improve test coverage PiperOrigin-RevId: 312266156 --- .../upstream/cache/CachedContent.java | 12 +- .../upstream/cache/SimpleCacheTest.java | 111 +++++++++++++----- 2 files changed, 93 insertions(+), 30 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 7abb9b3896..b6a55c8da4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import androidx.annotation.Nullable; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import java.io.File; import java.util.TreeSet; @@ -115,12 +117,18 @@ import java.util.TreeSet; * @return the length of the cached or not cached data block length. */ public long getCachedBytesLength(long position, long length) { + checkArgument(position >= 0); + checkArgument(length >= 0); SimpleCacheSpan span = getSpan(position); if (span.isHoleSpan()) { // We don't have a span covering the start of the queried region. return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length); } long queryEndPosition = position + length; + if (queryEndPosition < 0) { + // The calculation rolled over (length is probably Long.MAX_VALUE). + queryEndPosition = Long.MAX_VALUE; + } long currentEndPosition = span.position + span.length; if (currentEndPosition < queryEndPosition) { for (SimpleCacheSpan next : cachedSpans.tailSet(span, false)) { @@ -151,7 +159,7 @@ import java.util.TreeSet; */ public SimpleCacheSpan setLastTouchTimestamp( SimpleCacheSpan cacheSpan, long lastTouchTimestamp, boolean updateFile) { - Assertions.checkState(cachedSpans.remove(cacheSpan)); + checkState(cachedSpans.remove(cacheSpan)); File file = cacheSpan.file; if (updateFile) { File directory = file.getParentFile(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 4d9a936c4e..8294dee383 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -284,38 +284,93 @@ public class SimpleCacheTest { } @Test - public void testGetCachedLength() throws Exception { + public void getCachedLength_noCachedContent_returnsNegativeMaxHoleLength() { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); - // No cached bytes, returns -'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(-100); - - // Position value doesn't affect the return value - assertThat(simpleCache.getCachedLength(KEY_1, 20, 100)).isEqualTo(-100); - - addCache(simpleCache, KEY_1, 0, 15); - - // Returns the length of a single span - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(15); - - // Value is capped by the 'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 10)).isEqualTo(10); - - addCache(simpleCache, KEY_1, 15, 35); - - // Returns the length of two adjacent spans - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); - - addCache(simpleCache, KEY_1, 60, 10); - - // Not adjacent span doesn't affect return value - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); - - // Returns length of hole up to the next cached span - assertThat(simpleCache.getCachedLength(KEY_1, 55, 100)).isEqualTo(-5); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + } + @Test + public void getCachedLength_returnsNegativeHoleLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 50, /* length= */ 50); simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-30); + } + + @Test + public void getCachedLength_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 50); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); + addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleNonAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); + addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); } /* Tests https://github.com/google/ExoPlayer/issues/3260 case. */ From 5927d0302bc7e583b9f99c44a72d7a382b49bcdc Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 20 May 2020 14:32:52 +0100 Subject: [PATCH 0362/1052] Adding instructions on how to build and run ExoPlayer demo apps Issue:#7338 PiperOrigin-RevId: 312470913 --- demos/README.md | 21 +++++++++++++++++++++ demos/cast/README.md | 3 +++ demos/gl/README.md | 3 +++ demos/main/README.md | 3 +++ demos/surface/README.md | 3 +++ 5 files changed, 33 insertions(+) diff --git a/demos/README.md b/demos/README.md index 7e62249db1..2360e01137 100644 --- a/demos/README.md +++ b/demos/README.md @@ -2,3 +2,24 @@ This directory contains applications that demonstrate how to use ExoPlayer. Browse the individual demos and their READMEs to learn more. + +## Running a demo ## + +### From Android Studio ### + +* File -> New -> Import Project -> Specify the root ExoPlayer folder. +* Choose the demo from the run configuration dropdown list. +* Click Run. + +### Using gradle from the command line: ### + +* Open a Terminal window at the root ExoPlayer folder. +* Run `./gradlew projects` to show all projects. Demo projects start with `demo`. +* Run `./gradlew ::tasks` to view the list of available tasks for +the demo project. Choose an install option from the `Install tasks` section. +* Run `./gradlew ::`. + +**Example**: + +`./gradlew :demo:installNoExtensionsDebug` installs the main ExoPlayer demo app + in debug mode with no extensions. diff --git a/demos/cast/README.md b/demos/cast/README.md index 2c68a5277a..fd682433f9 100644 --- a/demos/cast/README.md +++ b/demos/cast/README.md @@ -2,3 +2,6 @@ This folder contains a demo application that showcases ExoPlayer integration with Google Cast. + +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. diff --git a/demos/gl/README.md b/demos/gl/README.md index 12dabe902b..9bffc3edea 100644 --- a/demos/gl/README.md +++ b/demos/gl/README.md @@ -8,4 +8,7 @@ drawn using an Android canvas, and includes the current frame's presentation timestamp, to show how to get the timestamp of the frame currently in the off-screen surface texture. +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. + [GLSurfaceView]: https://developer.android.com/reference/android/opengl/GLSurfaceView diff --git a/demos/main/README.md b/demos/main/README.md index bdb04e5ba8..00072c070b 100644 --- a/demos/main/README.md +++ b/demos/main/README.md @@ -3,3 +3,6 @@ This is the main ExoPlayer demo application. It uses ExoPlayer to play a number of test streams. It can be used as a starting point or reference project when developing other applications that make use of the ExoPlayer library. + +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. diff --git a/demos/surface/README.md b/demos/surface/README.md index 312259dbf6..3febb23feb 100644 --- a/demos/surface/README.md +++ b/demos/surface/README.md @@ -18,4 +18,7 @@ called, and because you can move output off-screen easily (`setOutputSurface` can't take a `null` surface, so the player has to use a `DummySurface`, which doesn't handle protected output on all devices). +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. + [SurfaceControl]: https://developer.android.com/reference/android/view/SurfaceControl From b05e9944ea95c2b1a341610568e5cfbe8df6f333 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 26 May 2020 08:52:37 +0100 Subject: [PATCH 0363/1052] Update TrackSelectionDialogBuilder to use androidx compat Dialog. This ensure style themes are correctly applied. issue:#7357 PiperOrigin-RevId: 313145345 --- RELEASENOTES.md | 5 ++++- library/ui/build.gradle | 1 + .../android/exoplayer2/ui/TrackSelectionDialogBuilder.java | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4126bdb5e0..16a0f33e60 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -21,7 +21,10 @@ ([#7337](https://github.com/google/ExoPlayer/issues/7337)). * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). -* Text +* UI: + * Update `TrackSelectionDialogBuilder` to use androidx compat Dialog + ([#7357](https://github.com/google/ExoPlayer/issues/7357)). +* Text: * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles ([#6950](https://github.com/google/ExoPlayer/pull/6950)). * AV1 extension: Add a heuristic to determine the default number of threads diff --git a/library/ui/build.gradle b/library/ui/build.gradle index b6bf139963..8727ba416a 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation project(modulePrefix + 'library-core') api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java index f8a016bc8b..5c91645a4c 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java @@ -15,12 +15,12 @@ */ package com.google.android.exoplayer2.ui; -import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; From 7e02066f6e7934dc1c063775d35bb460e66e5a01 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 27 May 2020 19:01:12 +0100 Subject: [PATCH 0364/1052] Merge pull request #7422 from noamtamim:bandwidthmeter-5g PiperOrigin-RevId: 313372995 --- .../android/exoplayer2/upstream/DefaultBandwidthMeter.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index 44ade5ea4f..6cbc17d3e0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -203,10 +203,11 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList result.append(C.NETWORK_TYPE_2G, DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[1]]); result.append(C.NETWORK_TYPE_3G, DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[2]]); result.append(C.NETWORK_TYPE_4G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); - // Assume default Wifi bitrate for Ethernet and 5G to prevent using the slower fallback. + // Assume default Wifi and 4G bitrate for Ethernet and 5G, respectively, to prevent using the + // slower fallback. result.append( C.NETWORK_TYPE_ETHERNET, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); - result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); + result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); return result; } From 9d6a46bccd35e1074a67b0871a646c9898199ce8 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 27 May 2020 21:57:37 +0100 Subject: [PATCH 0365/1052] Clean up release notes --- RELEASENOTES.md | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 16a0f33e60..e448756339 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,15 +1,14 @@ # Release notes # -### Next release ### +### 2.11.5 (not yet released) ### +* Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). -* Add `SilenceMediaSource.Factory` to support tags - ([PR #7245](https://github.com/google/ExoPlayer/pull/7245)). -* Avoid throwing an exception while parsing fragmented MP4 default sample - values where the most-significant bit is set - ([#7207](https://github.com/google/ExoPlayer/issues/7207)). -* Fix `AdsMediaSource` child `MediaSource`s not being released. +* DownloadService: Fix "Not allowed to start service" `IllegalStateException` + ([#7306](https://github.com/google/ExoPlayer/issues/7306)). +* Ads: + * Fix `AdsMediaSource` child `MediaSource`s not being released. * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as @@ -17,27 +16,29 @@ marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * Fix assertion failure in `SampleQueue` when playing DASH streams with EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). -* MP3: Allow MP3 files with XING headers that are larger than 2GB to be played +* FMP4: Avoid throwing an exception while parsing default sample values whose + most significant bits are set + ([#7207](https://github.com/google/ExoPlayer/issues/7207)). +* MP3: Fix issue parsing the XING headers belonging to files larger than 2GB ([#7337](https://github.com/google/ExoPlayer/issues/7337)). * MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). * UI: - * Update `TrackSelectionDialogBuilder` to use androidx compat Dialog + * Fix `DefaultTimeBar` to respect touch transformations + ([#7303](https://github.com/google/ExoPlayer/issues/7303)). + * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog ([#7357](https://github.com/google/ExoPlayer/issues/7357)). -* Text: - * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles - ([#6950](https://github.com/google/ExoPlayer/pull/6950)). -* AV1 extension: Add a heuristic to determine the default number of threads - used for AV1 playback using the extension. -* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* Text: Use anti-aliasing and bitmap filtering when displaying bitmap + subtitles. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when embedding the library. -* MediaSession extension: Set session playback state to BUFFERING only when - actually playing ([#7367](https://github.com/google/ExoPlayer/pull/7367), - [#7206](https://github.com/google/ExoPlayer/issues/7206)). -* DownloadService: Fix "Not allowed to start service" `IllegalStateException`. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* MediaSession extension: Set session playback state to `BUFFERING` only when + actually playing ([#7206](https://github.com/google/ExoPlayer/issues/7206)). +* AV1 extension: Add a heuristic to determine the default number of threads + used for AV1 playback using the extension. ### 2.11.4 (2020-04-08) ### From 68059070f4069ccec06f9ab21e780f46e299ebb7 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 22 Apr 2020 16:33:42 +0100 Subject: [PATCH 0366/1052] Add missing nullable annotations The MediaSessionConnector gets a Bundle passed to the MediaSession.Callback from the framework which can be null. This needs to be properly annotated with @Nullable. Issue: #7234 PiperOrigin-RevId: 307822764 --- RELEASENOTES.md | 7 +++-- .../mediasession/MediaSessionConnector.java | 30 +++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e448756339..a645a17713 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -35,8 +35,11 @@ negligible increase in application size, compared to approximately 8MB when embedding the library. * OkHttp extension: Upgrade OkHttp dependency to 3.12.11. -* MediaSession extension: Set session playback state to `BUFFERING` only when - actually playing ([#7206](https://github.com/google/ExoPlayer/issues/7206)). +* MediaSession extension: + * One set the playback state to `BUFFERING` if `playWhenReady` is true + ([#7206](https://github.com/google/ExoPlayer/issues/7206)). + * Add missing `@Nullable` annotations to `MediaSessionConnector` + ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index 0847686d21..646351aefe 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -218,25 +218,25 @@ public final class MediaSessionConnector { * * @param mediaId The media id of the media item to be prepared. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromMediaId(String mediaId, boolean playWhenReady, Bundle extras); + void onPrepareFromMediaId(String mediaId, boolean playWhenReady, @Nullable Bundle extras); /** * See {@link MediaSessionCompat.Callback#onPrepareFromSearch(String, Bundle)}. * * @param query The search query. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromSearch(String query, boolean playWhenReady, Bundle extras); + void onPrepareFromSearch(String query, boolean playWhenReady, @Nullable Bundle extras); /** * See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}. * * @param uri The {@link Uri} of the media item to be prepared. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromUri(Uri uri, boolean playWhenReady, Bundle extras); + void onPrepareFromUri(Uri uri, boolean playWhenReady, @Nullable Bundle extras); } /** @@ -336,7 +336,7 @@ public final class MediaSessionConnector { void onSetRating(Player player, RatingCompat rating); /** See {@link MediaSessionCompat.Callback#onSetRating(RatingCompat, Bundle)}. */ - void onSetRating(Player player, RatingCompat rating, Bundle extras); + void onSetRating(Player player, RatingCompat rating, @Nullable Bundle extras); } /** Handles requests for enabling or disabling captions. */ @@ -381,7 +381,7 @@ public final class MediaSessionConnector { * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching * changes to the player. * @param action The name of the action which was sent by a media controller. - * @param extras Optional extras sent by a media controller. + * @param extras Optional extras sent by a media controller, may be null. */ void onCustomAction( Player player, ControlDispatcher controlDispatcher, String action, @Nullable Bundle extras); @@ -1321,42 +1321,42 @@ public final class MediaSessionConnector { } @Override - public void onPrepareFromMediaId(String mediaId, Bundle extras) { + public void onPrepareFromMediaId(String mediaId, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID)) { playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ false, extras); } } @Override - public void onPrepareFromSearch(String query, Bundle extras) { + public void onPrepareFromSearch(String query, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH)) { playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ false, extras); } } @Override - public void onPrepareFromUri(Uri uri, Bundle extras) { + public void onPrepareFromUri(Uri uri, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_URI)) { playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ false, extras); } } @Override - public void onPlayFromMediaId(String mediaId, Bundle extras) { + public void onPlayFromMediaId(String mediaId, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID)) { playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ true, extras); } } @Override - public void onPlayFromSearch(String query, Bundle extras) { + public void onPlayFromSearch(String query, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH)) { playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ true, extras); } } @Override - public void onPlayFromUri(Uri uri, Bundle extras) { + public void onPlayFromUri(Uri uri, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_URI)) { playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ true, extras); } @@ -1370,7 +1370,7 @@ public final class MediaSessionConnector { } @Override - public void onSetRating(RatingCompat rating, Bundle extras) { + public void onSetRating(RatingCompat rating, @Nullable Bundle extras) { if (canDispatchSetRating()) { ratingCallback.onSetRating(player, rating, extras); } From d14f559e942792b007f81f8e1bf60cb39279850f Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 30 Apr 2020 15:01:44 +0100 Subject: [PATCH 0367/1052] Fix message indexing bug. We keep an index hint for the next pending player message. This hint wasn't updated correctly when messages are removed due to a timeline update. This change makes sure to only use the hint locally in one method so that it doesn't need to be updated anywhere else and also adds the "hint" suffix to the variable name to make it clearer that it's just a hint and there are no guarantees this index actually exists anymore. issue:#7278 PiperOrigin-RevId: 309217614 --- RELEASENOTES.md | 6 +++++- .../google/android/exoplayer2/ExoPlayerImplInternal.java | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a645a17713..697c78265f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,7 +5,11 @@ * Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). -* DownloadService: Fix "Not allowed to start service" `IllegalStateException` +* Fix bug where `PlayerMessages` throw an exception after `MediaSources` + are removed from the playlist + ([#7278](https://github.com/google/ExoPlayer/issues/7278)). +* Fix "Not allowed to start service" `IllegalStateException` in + `DownloadService` ([#7306](https://github.com/google/ExoPlayer/issues/7306)). * Ads: * Fix `AdsMediaSource` child `MediaSource`s not being released. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 108d94abb4..ddc54e9e6e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -120,7 +120,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private int pendingPrepareCount; private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; - private int nextPendingMessageIndex; + private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; public ExoPlayerImplInternal( @@ -928,7 +928,6 @@ import java.util.concurrent.atomic.AtomicBoolean; pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); } pendingMessages.clear(); - nextPendingMessageIndex = 0; } MediaPeriodId mediaPeriodId = resetPosition @@ -1082,6 +1081,7 @@ import java.util.concurrent.atomic.AtomicBoolean; // Correct next index if necessary (e.g. after seeking, timeline changes, or new messages) int currentPeriodIndex = playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); + int nextPendingMessageIndex = Math.min(nextPendingMessageIndexHint, pendingMessages.size()); PendingMessageInfo previousInfo = nextPendingMessageIndex > 0 ? pendingMessages.get(nextPendingMessageIndex - 1) : null; while (previousInfo != null @@ -1127,6 +1127,7 @@ import java.util.concurrent.atomic.AtomicBoolean; ? pendingMessages.get(nextPendingMessageIndex) : null; } + nextPendingMessageIndexHint = nextPendingMessageIndex; } private void ensureStopped(Renderer renderer) throws ExoPlaybackException { From 10b8eff727368276424caf1398ac7adc93b25690 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 6 Dec 2019 13:21:46 +0000 Subject: [PATCH 0368/1052] Don't overwrite MP4 container fps using capture fps The capture frame rate is currently available both via Format.metadata and decoded in Format.frameRate. As the container Format.frameRate may be useful to apps, only store the capture frame rate in metadata (apps will need to decode it but can now access the container frame rate too). PiperOrigin-RevId: 284165711 --- RELEASENOTES.md | 2 + .../extractor/mp4/MetadataUtil.java | 13 +--- .../assets/mp4/sample_android_slow_motion.mp4 | Bin 0 -> 43481 bytes .../mp4/sample_android_slow_motion.mp4.0.dump | 61 ++++++++++++++++++ .../mp4/sample_android_slow_motion.mp4.1.dump | 61 ++++++++++++++++++ .../mp4/sample_android_slow_motion.mp4.2.dump | 61 ++++++++++++++++++ .../mp4/sample_android_slow_motion.mp4.3.dump | 61 ++++++++++++++++++ .../extractor/mp4/Mp4ExtractorTest.java | 5 ++ 8 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 library/core/src/test/assets/mp4/sample_android_slow_motion.mp4 create mode 100644 library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.0.dump create mode 100644 library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.1.dump create mode 100644 library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.2.dump create mode 100644 library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.3.dump diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 697c78265f..cfb05784c7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -20,6 +20,8 @@ marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * Fix assertion failure in `SampleQueue` when playing DASH streams with EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). +* MP4: Store the Android capture frame rate only in `Format.metadata`. + `Format.frameRate` now stores the calculated frame rate. * FMP4: Avoid throwing an exception while parsing default sample values whose most significant bits are set ([#7207](https://github.com/google/ExoPlayer/issues/7207)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java index 732a69cecd..4f65836b76 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.metadata.id3.InternalFrame; import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableByteArray; -import java.nio.ByteBuffer; /** Utilities for handling metadata in MP4. */ /* package */ final class MetadataUtil { @@ -282,7 +281,6 @@ import java.nio.ByteBuffer; private static final int TYPE_TOP_BYTE_REPLACEMENT = 0xFD; // Truncated value of \uFFFD. private static final String MDTA_KEY_ANDROID_CAPTURE_FPS = "com.android.capture.fps"; - private static final int MDTA_TYPE_INDICATOR_FLOAT = 23; private MetadataUtil() {} @@ -312,15 +310,8 @@ import java.nio.ByteBuffer; Metadata.Entry entry = mdtaMetadata.get(i); if (entry instanceof MdtaMetadataEntry) { MdtaMetadataEntry mdtaMetadataEntry = (MdtaMetadataEntry) entry; - if (MDTA_KEY_ANDROID_CAPTURE_FPS.equals(mdtaMetadataEntry.key) - && mdtaMetadataEntry.typeIndicator == MDTA_TYPE_INDICATOR_FLOAT) { - try { - float fps = ByteBuffer.wrap(mdtaMetadataEntry.value).asFloatBuffer().get(); - format = format.copyWithFrameRate(fps); - format = format.copyWithMetadata(new Metadata(mdtaMetadataEntry)); - } catch (NumberFormatException e) { - Log.w(TAG, "Ignoring invalid framerate"); - } + if (MDTA_KEY_ANDROID_CAPTURE_FPS.equals(mdtaMetadataEntry.key)) { + format = format.copyWithMetadata(new Metadata(mdtaMetadataEntry)); } } } diff --git a/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4 b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..e5594c83e1f6f1ec8f21b79132525a1178cb7b71 GIT binary patch literal 43481 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

    ^F1H#F? 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 hHMA0o%; Date: Tue, 11 Feb 2020 05:01:55 +0000 Subject: [PATCH 0369/1052] Apply minimal layout change according to view size - Add method to scale scrubber handle of DefaultTimeBar PiperOrigin-RevId: 294366734 --- .../android/exoplayer2/ui/DefaultTimeBar.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 8c20d441b2..9c6acc9b2b 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -203,6 +203,7 @@ public class DefaultTimeBar extends View implements TimeBar { private int lastCoarseScrubXPosition; @MonotonicNonNull private Rect lastExclusionRectangle; + private float scrubberScale; private boolean scrubbing; private long scrubPosition; private long duration; @@ -329,6 +330,7 @@ public class DefaultTimeBar extends View implements TimeBar { (Math.max(scrubberDisabledSize, Math.max(scrubberEnabledSize, scrubberDraggedSize)) + 1) / 2; } + scrubberScale = 1.0f; duration = C.TIME_UNSET; keyTimeIncrement = C.TIME_UNSET; keyCountIncrement = DEFAULT_INCREMENT_COUNT; @@ -359,6 +361,18 @@ public class DefaultTimeBar extends View implements TimeBar { invalidate(seekBounds); } + /** + * Sets the scale factor for the scrubber handle. Scrubber enabled size, scrubber disabled size, + * scrubber dragged size are scaled by the scale factor. If scrubber drawable is set, the scale + * factor isn't applied. + * + * @param scrubberScale The scale factor for the scrubber handle. + */ + public void setScrubberScale(float scrubberScale) { + this.scrubberScale = scrubberScale; + invalidate(seekBounds); + } + /** * Sets the color for the portion of the time bar after the current played position up to the * current buffered position. @@ -815,7 +829,7 @@ public class DefaultTimeBar extends View implements TimeBar { if (scrubberDrawable == null) { int scrubberSize = (scrubbing || isFocused()) ? scrubberDraggedSize : (isEnabled() ? scrubberEnabledSize : scrubberDisabledSize); - int playheadRadius = scrubberSize / 2; + int playheadRadius = (int) ((scrubberSize * scrubberScale) / 2); canvas.drawCircle(playheadX, playheadY, playheadRadius, scrubberPaint); } else { int scrubberDrawableWidth = scrubberDrawable.getIntrinsicWidth(); From d162c07ecf8472dfab532a48bfa9d6a56bd73fc7 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 11 Mar 2020 05:01:42 +0000 Subject: [PATCH 0370/1052] Add show/hideScrubber to DefaultTimeBar PiperOrigin-RevId: 300249371 --- RELEASENOTES.md | 1 + .../android/exoplayer2/ui/DefaultTimeBar.java | 65 +++++++++++++++---- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cfb05784c7..8ef653d936 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -32,6 +32,7 @@ * UI: * Fix `DefaultTimeBar` to respect touch transformations ([#7303](https://github.com/google/ExoPlayer/issues/7303)). + * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Text: Use anti-aliasing and bitmap filtering when displaying bitmap diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 9c6acc9b2b..0d1f21b167 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ui; import android.annotation.TargetApi; +import android.animation.ValueAnimator; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; @@ -166,6 +167,9 @@ public class DefaultTimeBar extends View implements TimeBar { private static final int DEFAULT_INCREMENT_COUNT = 20; + private static final float SHOWN_SCRUBBER_SCALE = 1.0f; + private static final float HIDDEN_SCRUBBER_SCALE = 0.0f; + /** * The name of the Android SDK view that most closely resembles this custom view. Used as the * class name for accessibility. @@ -203,6 +207,7 @@ public class DefaultTimeBar extends View implements TimeBar { private int lastCoarseScrubXPosition; @MonotonicNonNull private Rect lastExclusionRectangle; + private ValueAnimator scrubberScalingAnimator; private float scrubberScale; private boolean scrubbing; private long scrubPosition; @@ -331,6 +336,12 @@ public class DefaultTimeBar extends View implements TimeBar { / 2; } scrubberScale = 1.0f; + scrubberScalingAnimator = new ValueAnimator(); + scrubberScalingAnimator.addUpdateListener( + animation -> { + scrubberScale = (float) animation.getAnimatedValue(); + invalidate(seekBounds); + }); duration = C.TIME_UNSET; keyTimeIncrement = C.TIME_UNSET; keyCountIncrement = DEFAULT_INCREMENT_COUNT; @@ -340,6 +351,44 @@ public class DefaultTimeBar extends View implements TimeBar { } } + /** Shows the scrubber handle. */ + public void showScrubber() { + showScrubber(/* showAnimationDurationMs= */ 0); + } + + /** + * Shows the scrubber handle with animation. + * + * @param showAnimationDurationMs The duration for scrubber showing animation. + */ + public void showScrubber(long showAnimationDurationMs) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberScalingAnimator.setFloatValues(scrubberScale, SHOWN_SCRUBBER_SCALE); + scrubberScalingAnimator.setDuration(showAnimationDurationMs); + scrubberScalingAnimator.start(); + } + + /** Hides the scrubber handle. */ + public void hideScrubber() { + hideScrubber(/* hideAnimationDurationMs= */ 0); + } + + /** + * Hides the scrubber handle with animation. + * + * @param hideAnimationDurationMs The duration for scrubber hiding animation. + */ + public void hideScrubber(long hideAnimationDurationMs) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberScalingAnimator.setFloatValues(scrubberScale, HIDDEN_SCRUBBER_SCALE); + scrubberScalingAnimator.setDuration(hideAnimationDurationMs); + scrubberScalingAnimator.start(); + } + /** * Sets the color for the portion of the time bar representing media before the playback position. * @@ -361,18 +410,6 @@ public class DefaultTimeBar extends View implements TimeBar { invalidate(seekBounds); } - /** - * Sets the scale factor for the scrubber handle. Scrubber enabled size, scrubber disabled size, - * scrubber dragged size are scaled by the scale factor. If scrubber drawable is set, the scale - * factor isn't applied. - * - * @param scrubberScale The scale factor for the scrubber handle. - */ - public void setScrubberScale(float scrubberScale) { - this.scrubberScale = scrubberScale; - invalidate(seekBounds); - } - /** * Sets the color for the portion of the time bar after the current played position up to the * current buffered position. @@ -832,8 +869,8 @@ public class DefaultTimeBar extends View implements TimeBar { int playheadRadius = (int) ((scrubberSize * scrubberScale) / 2); canvas.drawCircle(playheadX, playheadY, playheadRadius, scrubberPaint); } else { - int scrubberDrawableWidth = scrubberDrawable.getIntrinsicWidth(); - int scrubberDrawableHeight = scrubberDrawable.getIntrinsicHeight(); + int scrubberDrawableWidth = (int) (scrubberDrawable.getIntrinsicWidth() * scrubberScale); + int scrubberDrawableHeight = (int) (scrubberDrawable.getIntrinsicHeight() * scrubberScale); scrubberDrawable.setBounds( playheadX - scrubberDrawableWidth / 2, playheadY - scrubberDrawableHeight / 2, From 4e6fe31ee1aebabb8ab8503d25ed495e389ec35a Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Thu, 30 Jan 2020 19:30:48 +0000 Subject: [PATCH 0371/1052] Merge pull request #6724 from nnoury:fix/subtitles-outline-color PiperOrigin-RevId: 292316767 --- RELEASENOTES.md | 7 ++- .../exoplayer2/ui/SubtitlePainter.java | 51 +++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8ef653d936..0dca43f909 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -35,8 +35,11 @@ * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog ([#7357](https://github.com/google/ExoPlayer/issues/7357)). -* Text: Use anti-aliasing and bitmap filtering when displaying bitmap - subtitles. +* Text: + * Use anti-aliasing and bitmap filtering when displaying bitmap + subtitles. + * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct + color. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java index 714d40ff9a..2258f528d4 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java @@ -33,6 +33,7 @@ import android.text.TextPaint; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; import android.util.DisplayMetrics; import androidx.annotation.Nullable; @@ -99,6 +100,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Derived drawing variables. private @MonotonicNonNull StaticLayout textLayout; + private @MonotonicNonNull StaticLayout edgeLayout; private int textLeft; private int textTop; private int textPaddingX; @@ -291,11 +293,38 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } + // Remove embedded font color to not destroy edges, otherwise it overrides edge color. + SpannableStringBuilder cueTextEdge = new SpannableStringBuilder(cueText); + if (edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) { + int cueLength = cueTextEdge.length(); + ForegroundColorSpan[] foregroundColorSpans = + cueTextEdge.getSpans(0, cueLength, ForegroundColorSpan.class); + for (ForegroundColorSpan foregroundColorSpan : foregroundColorSpans) { + cueTextEdge.removeSpan(foregroundColorSpan); + } + } + + // EDGE_TYPE_NONE & EDGE_TYPE_DROP_SHADOW both paint in one pass, they ignore cueTextEdge. + // In other cases we use two painters and we need to apply the background in the first one only, + // otherwise the background color gets drawn in front of the edge color + // (https://github.com/google/ExoPlayer/pull/6724#issuecomment-564650572). if (Color.alpha(backgroundColor) > 0) { - SpannableStringBuilder newCueText = new SpannableStringBuilder(cueText); - newCueText.setSpan( - new BackgroundColorSpan(backgroundColor), 0, newCueText.length(), Spanned.SPAN_PRIORITY); - cueText = newCueText; + if (edgeType == CaptionStyleCompat.EDGE_TYPE_NONE + || edgeType == CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW) { + SpannableStringBuilder newCueText = new SpannableStringBuilder(cueText); + newCueText.setSpan( + new BackgroundColorSpan(backgroundColor), + 0, + newCueText.length(), + Spanned.SPAN_PRIORITY); + cueText = newCueText; + } else { + cueTextEdge.setSpan( + new BackgroundColorSpan(backgroundColor), + 0, + cueTextEdge.length(), + Spanned.SPAN_PRIORITY); + } } Alignment textAlignment = cueTextAlignment == null ? Alignment.ALIGN_CENTER : cueTextAlignment; @@ -371,6 +400,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Update the derived drawing variables. this.textLayout = new StaticLayout(cueText, textPaint, textWidth, textAlignment, spacingMult, spacingAdd, true); + this.edgeLayout = + new StaticLayout( + cueTextEdge, textPaint, textWidth, textAlignment, spacingMult, spacingAdd, true); this.textLeft = textLeft; this.textTop = textTop; this.textPaddingX = textPaddingX; @@ -410,8 +442,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } private void drawTextLayout(Canvas canvas) { - StaticLayout layout = textLayout; - if (layout == null) { + StaticLayout textLayout = this.textLayout; + StaticLayout edgeLayout = this.edgeLayout; + if (textLayout == null || edgeLayout == null) { // Nothing to draw. return; } @@ -434,7 +467,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; textPaint.setStrokeWidth(outlineWidth); textPaint.setColor(edgeColor); textPaint.setStyle(Style.FILL_AND_STROKE); - layout.draw(canvas); + edgeLayout.draw(canvas); } else if (edgeType == CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW) { textPaint.setShadowLayer(shadowRadius, shadowOffset, shadowOffset, edgeColor); } else if (edgeType == CaptionStyleCompat.EDGE_TYPE_RAISED @@ -446,13 +479,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; textPaint.setColor(foregroundColor); textPaint.setStyle(Style.FILL); textPaint.setShadowLayer(shadowRadius, -offset, -offset, colorUp); - layout.draw(canvas); + edgeLayout.draw(canvas); textPaint.setShadowLayer(shadowRadius, offset, offset, colorDown); } textPaint.setColor(foregroundColor); textPaint.setStyle(Style.FILL); - layout.draw(canvas); + textLayout.draw(canvas); textPaint.setShadowLayer(0, 0, 0, 0); canvas.restoreToCount(saveCount); From cb85cdd3eb34cc5c263c790473a7f83ac74863ec Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Thu, 28 May 2020 12:36:48 +0100 Subject: [PATCH 0372/1052] Fix typo in release notes --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0dca43f909..a82e2c19e8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -46,7 +46,7 @@ embedding the library. * OkHttp extension: Upgrade OkHttp dependency to 3.12.11. * MediaSession extension: - * One set the playback state to `BUFFERING` if `playWhenReady` is true + * Only set the playback state to `BUFFERING` if `playWhenReady` is true ([#7206](https://github.com/google/ExoPlayer/issues/7206)). * Add missing `@Nullable` annotations to `MediaSessionConnector` ([#7234](https://github.com/google/ExoPlayer/issues/7234)). From b3de2b08d456676023f0883d4abe8e73667ceca6 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 11 Dec 2019 11:12:01 +0000 Subject: [PATCH 0373/1052] Inline ENABLE_PRELOADING ImaAdsLoader relies on preloading being enabled (it doesn't work without it) so we may as well remove the constant to avoid potential confusion. PiperOrigin-RevId: 284951356 --- .../google/android/exoplayer2/ext/ima/ImaAdsLoader.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index a37294365c..65a8bbe44d 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -272,11 +272,6 @@ public final class ImaAdsLoader private static final boolean DEBUG = false; private static final String TAG = "ImaAdsLoader"; - /** - * Whether to enable preloading of ads in {@link AdsRenderingSettings}. - */ - private static final boolean ENABLE_PRELOADING = true; - private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima"; private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION; @@ -1055,7 +1050,7 @@ public final class ImaAdsLoader private void initializeAdsManager() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); - adsRenderingSettings.setEnablePreloading(ENABLE_PRELOADING); + adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes(supportedMimeTypes); if (mediaLoadTimeoutMs != TIMEOUT_UNSET) { adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs); From 03be1551a7926cbb64dbf4925c0e5ede2542efee Mon Sep 17 00:00:00 2001 From: Andrew Lewis Date: Fri, 29 May 2020 20:53:06 +0100 Subject: [PATCH 0374/1052] Clean up player event handling This change is similar to e8293b92d877bd6e5a00606358db610d6fb7a4d6 but without relying on new player events that haven't been released yet, to make it easier to merge changes related to ImaAdsLoader on top. --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 90 ++++++++++--------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 65a8bbe44d..5f7d621b10 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -332,7 +332,7 @@ public final class ImaAdsLoader private VideoProgressUpdate lastAdProgress; private int lastVolumePercentage; - private AdsManager adsManager; + @Nullable private AdsManager adsManager; private boolean initializedAdsManager; private AdLoadException pendingAdLoadError; private Timeline timeline; @@ -975,12 +975,12 @@ public final class ImaAdsLoader initializedAdsManager = true; initializeAdsManager(); } - onPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL); + checkForContentCompleteOrNewAdGroup(); } @Override public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { - if (adsManager == null) { + if (adsManager == null || player == null) { return; } @@ -993,18 +993,7 @@ public final class ImaAdsLoader adsManager.resume(); return; } - - if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING - && playWhenReady) { - checkForContentComplete(); - } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); - } - if (DEBUG) { - Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlayerStateChanged"); - } - } + handlePlayerStateChanged(playWhenReady, playbackState); } @Override @@ -1018,32 +1007,7 @@ public final class ImaAdsLoader @Override public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { - if (adsManager == null) { - return; - } - if (!playingAd && !player.isPlayingAd()) { - checkForContentComplete(); - if (sentContentComplete) { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } - } - updateAdPlaybackState(); - } else if (!timeline.isEmpty()) { - long positionMs = player.getCurrentPosition(); - timeline.getPeriod(0, period); - int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); - if (newAdGroupIndex != C.INDEX_UNSET) { - sentPendingContentPositionMs = false; - pendingContentPositionMs = positionMs; - if (newAdGroupIndex != adGroupIndex) { - shouldNotifyAdPrepareError = false; - } - } - } - } - updateImaStateForPlayerState(); + checkForContentCompleteOrNewAdGroup(); } // Internal methods. @@ -1176,6 +1140,50 @@ public final class ImaAdsLoader } } + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (imaAdState == IMA_AD_STATE_NONE + && playbackState == Player.STATE_BUFFERING + && playWhenReady) { + checkForContentComplete(); + } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(); + } + if (DEBUG) { + Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlayerStateChanged"); + } + } + } + + private void checkForContentCompleteOrNewAdGroup() { + if (adsManager == null || player == null) { + return; + } + if (!playingAd && !player.isPlayingAd()) { + checkForContentComplete(); + if (sentContentComplete) { + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); + } + } + updateAdPlaybackState(); + } else if (!timeline.isEmpty()) { + long positionMs = player.getCurrentPosition(); + timeline.getPeriod(/* periodIndex= */ 0, period); + int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); + if (newAdGroupIndex != C.INDEX_UNSET) { + sentPendingContentPositionMs = false; + pendingContentPositionMs = positionMs; + if (newAdGroupIndex != adGroupIndex) { + shouldNotifyAdPrepareError = false; + } + } + } + } + updateImaStateForPlayerState(); + } + private void updateImaStateForPlayerState() { boolean wasPlayingAd = playingAd; int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; From d1b900604a86dc325ce39a7c753e05ccfab2403b Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 4 Mar 2020 14:06:34 +0000 Subject: [PATCH 0375/1052] Add test to ensure AdsLoader is initialized. This tests explicitly that initialization happens even if the Timeline is a placeholder. No other change is needed. While the Timeline is still a placeholder ImaAdsLoader.getCurrentPeriodPosition will return 0 and trigger pre-rolls (intended behaviour) and it doesn't matter whether the actual initial period position may be somewhere else. PiperOrigin-RevId: 298833867 --- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 55 ++++++++------ .../ext/ima/SingletonImaFactory.java | 72 ------------------- .../exoplayer2/source/MaskingMediaSource.java | 2 +- 3 files changed, 36 insertions(+), 93 deletions(-) delete mode 100644 extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index edaa4cde29..e6c0852bc8 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ext.ima; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; @@ -39,11 +40,14 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader.ImaFactory; +import com.google.android.exoplayer2.source.MaskingMediaSource; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; +import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; import java.util.Arrays; @@ -63,8 +67,11 @@ public class ImaAdsLoaderTest { private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final Timeline CONTENT_TIMELINE = - new SinglePeriodTimeline( - CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false); + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, CONTENT_DURATION_US)); + private static final long CONTENT_PERIOD_DURATION_US = + CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.EMPTY; private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; @@ -72,11 +79,11 @@ public class ImaAdsLoaderTest { private static final FakeAd UNSKIPPABLE_AD = new FakeAd(/* skippable= */ false, /* podIndex= */ 0, /* totalAds= */ 1, /* adPosition= */ 1); - private @Mock ImaSdkSettings imaSdkSettings; - private @Mock AdsRenderingSettings adsRenderingSettings; - private @Mock AdDisplayContainer adDisplayContainer; - private @Mock AdsManager adsManager; - private SingletonImaFactory testImaFactory; + @Mock private ImaSdkSettings imaSdkSettings; + @Mock private AdsRenderingSettings adsRenderingSettings; + @Mock private AdDisplayContainer adDisplayContainer; + @Mock private AdsManager adsManager; + @Mock private ImaFactory mockImaFactory; private ViewGroup adViewGroup; private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; @@ -89,13 +96,11 @@ public class ImaAdsLoaderTest { MockitoAnnotations.initMocks(this); FakeAdsRequest fakeAdsRequest = new FakeAdsRequest(); FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(imaSdkSettings, adsManager); - testImaFactory = - new SingletonImaFactory( - imaSdkSettings, - adsRenderingSettings, - adDisplayContainer, - fakeAdsRequest, - fakeAdsLoader); + when(mockImaFactory.createAdDisplayContainer()).thenReturn(adDisplayContainer); + when(mockImaFactory.createAdsRenderingSettings()).thenReturn(adsRenderingSettings); + when(mockImaFactory.createAdsRequest()).thenReturn(fakeAdsRequest); + when(mockImaFactory.createImaSdkSettings()).thenReturn(imaSdkSettings); + when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(fakeAdsLoader); adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext()); adOverlayView = new View(ApplicationProvider.getApplicationContext()); adViewProvider = @@ -136,6 +141,16 @@ public class ImaAdsLoaderTest { verify(adDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView); } + @Test + public void testStart_withPlaceholderContent_initializedAdsLoader() { + Timeline placeholderTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); + setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + // We'll only create the rendering settings when initializing the ads loader. + verify(mockImaFactory).createAdsRenderingSettings(); + } + @Test public void testStart_updatesAdPlaybackState() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); @@ -143,9 +158,9 @@ public class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs= */ 0) + new AdPlaybackState(/* adGroupTimesUs...= */ 0) .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) - .withContentDurationUs(CONTENT_DURATION_US)); + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test @@ -213,8 +228,8 @@ public class ImaAdsLoaderTest { // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs= */ 0) - .withContentDurationUs(CONTENT_DURATION_US) + new AdPlaybackState(/* adGroupTimesUs...= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) @@ -240,7 +255,7 @@ public class ImaAdsLoaderTest { when(adsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); imaAdsLoader = new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) - .setImaFactory(testImaFactory) + .setImaFactory(mockImaFactory) .setImaSdkSettings(imaSdkSettings) .buildForAdTag(TEST_URI); imaAdsLoader.setPlayer(fakeExoPlayer); diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java deleted file mode 100644 index 4efd8cf38c..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import android.content.Context; -import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; -import com.google.ads.interactivemedia.v3.api.AdsLoader; -import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; - -/** {@link ImaAdsLoader.ImaFactory} that returns provided instances from each getter, for tests. */ -final class SingletonImaFactory implements ImaAdsLoader.ImaFactory { - - private final ImaSdkSettings imaSdkSettings; - private final AdsRenderingSettings adsRenderingSettings; - private final AdDisplayContainer adDisplayContainer; - private final AdsRequest adsRequest; - private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; - - public SingletonImaFactory( - ImaSdkSettings imaSdkSettings, - AdsRenderingSettings adsRenderingSettings, - AdDisplayContainer adDisplayContainer, - AdsRequest adsRequest, - com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { - this.imaSdkSettings = imaSdkSettings; - this.adsRenderingSettings = adsRenderingSettings; - this.adDisplayContainer = adDisplayContainer; - this.adsRequest = adsRequest; - this.adsLoader = adsLoader; - } - - @Override - public ImaSdkSettings createImaSdkSettings() { - return imaSdkSettings; - } - - @Override - public AdsRenderingSettings createAdsRenderingSettings() { - return adsRenderingSettings; - } - - @Override - public AdDisplayContainer createAdDisplayContainer() { - return adDisplayContainer; - } - - @Override - public AdsRequest createAdsRequest() { - return adsRequest; - } - - @Override - public AdsLoader createAdsLoader( - Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { - return adsLoader; - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index 891cb351c1..47279f2358 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -293,7 +293,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { } /** Dummy placeholder timeline with one dynamic window with a period of indeterminate duration. */ - private static final class DummyTimeline extends Timeline { + public static final class DummyTimeline extends Timeline { @Nullable private final Object tag; From 67d1b728d35f8daeb4a80e251da90c1637601959 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 28 Feb 2020 11:57:56 +0000 Subject: [PATCH 0376/1052] Clarify/fix position reference points for AdPlaybackState. The positions were interchangeably used with window and period positions. This change more clearly ensures that all positions in the AdPlaybackState are based on periods and that we use the right adjustments for all usages. PiperOrigin-RevId: 297811633 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 33 +++++++++----- .../google/android/exoplayer2/Timeline.java | 21 ++++----- .../analytics/PlaybackStatsListener.java | 8 +++- .../source/ads/AdPlaybackState.java | 43 +++++++++++-------- 4 files changed, 65 insertions(+), 40 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 5f7d621b10..5d5d156b97 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -318,6 +318,7 @@ public final class ImaAdsLoader @Nullable private final AdEventListener adEventListener; private final ImaFactory imaFactory; private final Timeline.Period period; + private final Timeline.Window window; private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; @@ -469,6 +470,7 @@ public final class ImaAdsLoader imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); + window = new Timeline.Window(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); @@ -757,14 +759,16 @@ public final class ImaAdsLoader sentPendingContentPositionMs = true; contentPositionMs = pendingContentPositionMs; expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs)); + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs)); + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { - contentPositionMs = player.getCurrentPosition(); + contentPositionMs = getContentPeriodPositionMs(); // Update the expected ad group index for the current content position. The update is delayed // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered // just after an ad group isn't incorrectly attributed to the next ad group. @@ -966,7 +970,7 @@ public final class ImaAdsLoader } Assertions.checkArgument(timeline.getPeriodCount() == 1); this.timeline = timeline; - long contentDurationUs = timeline.getPeriod(0, period).durationUs; + long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; contentDurationMs = C.usToMs(contentDurationUs); if (contentDurationUs != C.TIME_UNSET) { adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); @@ -1029,9 +1033,10 @@ public final class ImaAdsLoader // Skip ads based on the start position as required. long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); - long contentPositionMs = player.getContentPosition(); + long contentPositionMs = getContentPeriodPositionMs(); int adGroupIndexForPosition = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs)); + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) { // Skip any ad groups before the one at or immediately before the playback position. for (int i = 0; i < adGroupIndexForPosition; i++) { @@ -1169,7 +1174,7 @@ public final class ImaAdsLoader } updateAdPlaybackState(); } else if (!timeline.isEmpty()) { - long positionMs = player.getCurrentPosition(); + long positionMs = getContentPeriodPositionMs(); timeline.getPeriod(/* periodIndex= */ 0, period); int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); if (newAdGroupIndex != C.INDEX_UNSET) { @@ -1311,8 +1316,9 @@ public final class ImaAdsLoader } private void checkForContentComplete() { - if (contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && player.getContentPosition() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs + if (contentDurationMs != C.TIME_UNSET + && pendingContentPositionMs == C.TIME_UNSET + && getContentPeriodPositionMs() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs && !sentContentComplete) { adsLoader.contentComplete(); if (DEBUG) { @@ -1322,7 +1328,8 @@ public final class ImaAdsLoader // After sending content complete IMA will not poll the content position, so set the expected // ad group index. expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentDurationMs)); + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentDurationMs), C.msToUs(contentDurationMs)); } } @@ -1374,6 +1381,12 @@ public final class ImaAdsLoader } } + private long getContentPeriodPositionMs() { + long contentWindowPositionMs = player.getContentPosition(); + return contentWindowPositionMs + - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); + } + private static long[] getAdGroupTimesUs(List cuePoints) { if (cuePoints.isEmpty()) { // If no cue points are specified, there is a preroll ad. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index 93a87da0dc..4dac71559a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -466,8 +466,8 @@ public abstract class Timeline { * microseconds. * * @param adGroupIndex The ad group index. - * @return The time of the ad group at the index, in microseconds, or {@link - * C#TIME_END_OF_SOURCE} for a post-roll ad group. + * @return The time of the ad group at the index relative to the start of the enclosing {@link + * Period}, in microseconds, or {@link C#TIME_END_OF_SOURCE} for a post-roll ad group. */ public long getAdGroupTimeUs(int adGroupIndex) { return adPlaybackState.adGroupTimesUs[adGroupIndex]; @@ -510,22 +510,23 @@ public abstract class Timeline { } /** - * Returns the index of the ad group at or before {@code positionUs}, if that ad group is - * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has - * no ads remaining to be played, or if there is no such ad group. + * Returns the index of the ad group at or before {@code positionUs} in the period, if that ad + * group is unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code + * positionUs} has no ads remaining to be played, or if there is no such ad group. * - * @param positionUs The position at or before which to find an ad group, in microseconds. + * @param positionUs The period position at or before which to find an ad group, in + * microseconds. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexForPositionUs(long positionUs) { - return adPlaybackState.getAdGroupIndexForPositionUs(positionUs); + return adPlaybackState.getAdGroupIndexForPositionUs(positionUs, durationUs); } /** - * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be - * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. + * Returns the index of the next ad group after {@code positionUs} in the period that has ads + * remaining to be played. Returns {@link C#INDEX_UNSET} if there is no such ad group. * - * @param positionUs The position after which to find an ad group, in microseconds. + * @param positionUs The period position after which to find an ad group, in microseconds. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexAfterPositionUs(long positionUs) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index a9fd9d8641..43d2496842 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -189,11 +189,15 @@ public final class PlaybackStatsListener @Override public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) { Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd()); - long contentPositionUs = + long contentPeriodPositionUs = eventTime .timeline .getPeriodByUid(eventTime.mediaPeriodId.periodUid, period) .getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex); + long contentWindowPositionUs = + contentPeriodPositionUs == C.TIME_END_OF_SOURCE + ? C.TIME_END_OF_SOURCE + : contentPeriodPositionUs + period.getPositionInWindowUs(); EventTime contentEventTime = new EventTime( eventTime.realtimeMs, @@ -203,7 +207,7 @@ public final class PlaybackStatsListener eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber, eventTime.mediaPeriodId.adGroupIndex), - /* eventPlaybackPositionMs= */ C.usToMs(contentPositionUs), + /* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs), eventTime.currentPlaybackPositionMs, eventTime.totalBufferedDurationMs); Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 0a1628b3f9..dee63d819e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -29,8 +29,7 @@ import java.util.Arrays; import org.checkerframework.checker.nullness.compatqual.NullableType; /** - * Represents ad group times relative to the start of the media and information on the state and - * URIs of ads within each ad group. + * Represents ad group times and information on the state and URIs of ads within each ad group. * *

      Instances are immutable. Call the {@code with*} methods to get new instances that have the * required changes. @@ -272,8 +271,9 @@ public final class AdPlaybackState { /** The number of ad groups. */ public final int adGroupCount; /** - * The times of ad groups, in microseconds. A final element with the value {@link - * C#TIME_END_OF_SOURCE} indicates a postroll ad. + * The times of ad groups, in microseconds, relative to the start of the {@link + * com.google.android.exoplayer2.Timeline.Period} they belong to. A final element with the value + * {@link C#TIME_END_OF_SOURCE} indicates a postroll ad. */ public final long[] adGroupTimesUs; /** The ad groups. */ @@ -286,8 +286,9 @@ public final class AdPlaybackState { /** * Creates a new ad playback state with the specified ad group times. * - * @param adGroupTimesUs The times of ad groups in microseconds. A final element with the value - * {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. + * @param adGroupTimesUs The times of ad groups in microseconds, relative to the start of the + * {@link com.google.android.exoplayer2.Timeline.Period} they belong to. A final element with + * the value {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. */ public AdPlaybackState(long... adGroupTimesUs) { int count = adGroupTimesUs.length; @@ -315,16 +316,18 @@ public final class AdPlaybackState { * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has no * ads remaining to be played, or if there is no such ad group. * - * @param positionUs The position at or before which to find an ad group, in microseconds, or - * {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case the index of any + * @param positionUs The period position at or before which to find an ad group, in microseconds, + * or {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case the index of any * unplayed postroll ad group will be returned). + * @param periodDurationUs The duration of the containing timeline period, in microseconds, or + * {@link C#TIME_UNSET} if not known. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ - public int getAdGroupIndexForPositionUs(long positionUs) { + public int getAdGroupIndexForPositionUs(long positionUs, long periodDurationUs) { // Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE. // In practice we expect there to be few ad groups so the search shouldn't be expensive. int index = adGroupTimesUs.length - 1; - while (index >= 0 && isPositionBeforeAdGroup(positionUs, index)) { + while (index >= 0 && isPositionBeforeAdGroup(positionUs, periodDurationUs, index)) { index--; } return index >= 0 && adGroups[index].hasUnplayedAds() ? index : C.INDEX_UNSET; @@ -334,11 +337,11 @@ public final class AdPlaybackState { * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. * - * @param positionUs The position after which to find an ad group, in microseconds, or {@link - * C#TIME_END_OF_SOURCE} for the end of the stream (in which case there can be no ad group - * after the position). - * @param periodDurationUs The duration of the containing period in microseconds, or {@link - * C#TIME_UNSET} if not known. + * @param positionUs The period position after which to find an ad group, in microseconds, or + * {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case there can be no ad + * group after the position). + * @param periodDurationUs The duration of the containing timeline period, in microseconds, or + * {@link C#TIME_UNSET} if not known. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexAfterPositionUs(long positionUs, long periodDurationUs) { @@ -425,7 +428,10 @@ public final class AdPlaybackState { return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } - /** Returns an instance with the specified ad resume position, in microseconds. */ + /** + * Returns an instance with the specified ad resume position, in microseconds, relative to the + * start of the current ad. + */ @CheckResult public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) { if (this.adResumePositionUs == adResumePositionUs) { @@ -471,14 +477,15 @@ public final class AdPlaybackState { return result; } - private boolean isPositionBeforeAdGroup(long positionUs, int adGroupIndex) { + private boolean isPositionBeforeAdGroup( + long positionUs, long periodDurationUs, int adGroupIndex) { if (positionUs == C.TIME_END_OF_SOURCE) { // The end of the content is at (but not before) any postroll ad, and after any other ads. return false; } long adGroupPositionUs = adGroupTimesUs[adGroupIndex]; if (adGroupPositionUs == C.TIME_END_OF_SOURCE) { - return contentDurationUs == C.TIME_UNSET || positionUs < contentDurationUs; + return periodDurationUs == C.TIME_UNSET || positionUs < periodDurationUs; } else { return positionUs < adGroupPositionUs; } From 6e66dc02fb9b7c86629f47adfb4b1231bd1676cf Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 28 Apr 2020 15:28:11 +0100 Subject: [PATCH 0377/1052] Replace IMA ad tag fakes with mocks The mocking setup is quite messy/unclear compared to the fakes, but it seems worth switching over because IMA API changes have already required changes to fakes in the past, and there are more API changes in the version we are about to upgrade to. This change should generally remove the need to keep the fakes up-to-date. PiperOrigin-RevId: 308819176 --- .../android/exoplayer2/ext/ima/FakeAd.java | 211 ------------------ .../exoplayer2/ext/ima/FakeAdsLoader.java | 100 --------- .../exoplayer2/ext/ima/FakeAdsRequest.java | 132 ----------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 146 ++++++++---- 4 files changed, 103 insertions(+), 486 deletions(-) delete mode 100644 extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java delete mode 100644 extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java delete mode 100644 extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java deleted file mode 100644 index 59dfc6473c..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.Ad; -import com.google.ads.interactivemedia.v3.api.AdPodInfo; -import com.google.ads.interactivemedia.v3.api.CompanionAd; -import com.google.ads.interactivemedia.v3.api.UiElement; -import java.util.List; -import java.util.Set; - -/** A fake ad for testing. */ -/* package */ final class FakeAd implements Ad { - - private final boolean skippable; - private final AdPodInfo adPodInfo; - - public FakeAd(boolean skippable, int podIndex, int totalAds, int adPosition) { - this.skippable = skippable; - adPodInfo = - new AdPodInfo() { - @Override - public int getTotalAds() { - return totalAds; - } - - @Override - public int getAdPosition() { - return adPosition; - } - - @Override - public int getPodIndex() { - return podIndex; - } - - @Override - public boolean isBumper() { - throw new UnsupportedOperationException(); - } - - @Override - public double getMaxDuration() { - throw new UnsupportedOperationException(); - } - - @Override - public double getTimeOffset() { - throw new UnsupportedOperationException(); - } - }; - } - - @Override - public int getVastMediaWidth() { - throw new UnsupportedOperationException(); - } - - @Override - public int getVastMediaHeight() { - throw new UnsupportedOperationException(); - } - - @Override - public int getVastMediaBitrate() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isSkippable() { - return skippable; - } - - @Override - public AdPodInfo getAdPodInfo() { - return adPodInfo; - } - - @Override - public String getAdId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getCreativeId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getCreativeAdId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getUniversalAdIdValue() { - throw new UnsupportedOperationException(); - } - - @Override - public String getUniversalAdIdRegistry() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAdSystem() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperIds() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperSystems() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperCreativeIds() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isLinear() { - throw new UnsupportedOperationException(); - } - - @Override - public double getSkipTimeOffset() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isUiDisabled() { - throw new UnsupportedOperationException(); - } - - @Override - public String getDescription() { - throw new UnsupportedOperationException(); - } - - @Override - public String getTitle() { - throw new UnsupportedOperationException(); - } - - @Override - public String getContentType() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAdvertiserName() { - throw new UnsupportedOperationException(); - } - - @Override - public String getSurveyUrl() { - throw new UnsupportedOperationException(); - } - - @Override - public String getDealId() { - throw new UnsupportedOperationException(); - } - - @Override - public int getWidth() { - throw new UnsupportedOperationException(); - } - - @Override - public int getHeight() { - throw new UnsupportedOperationException(); - } - - @Override - public String getTraffickingParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public double getDuration() { - throw new UnsupportedOperationException(); - } - - @Override - public Set getUiElements() { - throw new UnsupportedOperationException(); - } - - @Override - public List getCompanionAds() { - throw new UnsupportedOperationException(); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java deleted file mode 100644 index a8f3daae33..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; -import com.google.ads.interactivemedia.v3.api.AdsManager; -import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; -import com.google.ads.interactivemedia.v3.api.StreamManager; -import com.google.ads.interactivemedia.v3.api.StreamRequest; -import com.google.android.exoplayer2.util.Assertions; -import java.util.ArrayList; - -/** Fake {@link com.google.ads.interactivemedia.v3.api.AdsLoader} implementation for tests. */ -public final class FakeAdsLoader implements com.google.ads.interactivemedia.v3.api.AdsLoader { - - private final ImaSdkSettings imaSdkSettings; - private final AdsManager adsManager; - private final ArrayList adsLoadedListeners; - private final ArrayList adErrorListeners; - - public FakeAdsLoader(ImaSdkSettings imaSdkSettings, AdsManager adsManager) { - this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings); - this.adsManager = Assertions.checkNotNull(adsManager); - adsLoadedListeners = new ArrayList<>(); - adErrorListeners = new ArrayList<>(); - } - - @Override - public void contentComplete() { - // Do nothing. - } - - @Override - public ImaSdkSettings getSettings() { - return imaSdkSettings; - } - - @Override - public void requestAds(AdsRequest adsRequest) { - for (AdsLoadedListener listener : adsLoadedListeners) { - listener.onAdsManagerLoaded( - new AdsManagerLoadedEvent() { - @Override - public AdsManager getAdsManager() { - return adsManager; - } - - @Override - public StreamManager getStreamManager() { - throw new UnsupportedOperationException(); - } - - @Override - public Object getUserRequestContext() { - return adsRequest.getUserRequestContext(); - } - }); - } - } - - @Override - public String requestStream(StreamRequest streamRequest) { - throw new UnsupportedOperationException(); - } - - @Override - public void addAdsLoadedListener(AdsLoadedListener adsLoadedListener) { - adsLoadedListeners.add(adsLoadedListener); - } - - @Override - public void removeAdsLoadedListener(AdsLoadedListener adsLoadedListener) { - adsLoadedListeners.remove(adsLoadedListener); - } - - @Override - public void addAdErrorListener(AdErrorListener adErrorListener) { - adErrorListeners.add(adErrorListener); - } - - @Override - public void removeAdErrorListener(AdErrorListener adErrorListener) { - adErrorListeners.remove(adErrorListener); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java deleted file mode 100644 index 7c2c8a6e0b..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; -import java.util.List; -import java.util.Map; - -/** Fake {@link AdsRequest} implementation for tests. */ -public final class FakeAdsRequest implements AdsRequest { - - private String adTagUrl; - private String adsResponse; - private Object userRequestContext; - private AdDisplayContainer adDisplayContainer; - private ContentProgressProvider contentProgressProvider; - - @Override - public void setAdTagUrl(String adTagUrl) { - this.adTagUrl = adTagUrl; - } - - @Override - public String getAdTagUrl() { - return adTagUrl; - } - - @Override - public void setExtraParameter(String s, String s1) { - throw new UnsupportedOperationException(); - } - - @Override - public String getExtraParameter(String s) { - throw new UnsupportedOperationException(); - } - - @Override - public Map getExtraParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public void setUserRequestContext(Object userRequestContext) { - this.userRequestContext = userRequestContext; - } - - @Override - public Object getUserRequestContext() { - return userRequestContext; - } - - @Override - public AdDisplayContainer getAdDisplayContainer() { - return adDisplayContainer; - } - - @Override - public void setAdDisplayContainer(AdDisplayContainer adDisplayContainer) { - this.adDisplayContainer = adDisplayContainer; - } - - @Override - public ContentProgressProvider getContentProgressProvider() { - return contentProgressProvider; - } - - @Override - public void setContentProgressProvider(ContentProgressProvider contentProgressProvider) { - this.contentProgressProvider = contentProgressProvider; - } - - @Override - public String getAdsResponse() { - return adsResponse; - } - - @Override - public void setAdsResponse(String adsResponse) { - this.adsResponse = adsResponse; - } - - @Override - public void setAdWillAutoPlay(boolean b) { - throw new UnsupportedOperationException(); - } - - @Override - public void setAdWillPlayMuted(boolean b) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentDuration(float v) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentKeywords(List list) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentTitle(String s) { - throw new UnsupportedOperationException(); - } - - @Override - public void setVastLoadTimeout(float v) { - throw new UnsupportedOperationException(); - } - - @Override - public void setLiveStreamPrefetchSeconds(float v) { - throw new UnsupportedOperationException(); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index e6c0852bc8..d50fff5ae8 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -18,6 +18,8 @@ package com.google.android.exoplayer2.ext.ima; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -33,8 +35,11 @@ import com.google.ads.interactivemedia.v3.api.Ad; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -48,22 +53,29 @@ import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.stubbing.Answer; /** Test for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) -public class ImaAdsLoaderTest { +public final class ImaAdsLoaderTest { private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final Timeline CONTENT_TIMELINE = @@ -76,14 +88,20 @@ public class ImaAdsLoaderTest { private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; - private static final FakeAd UNSKIPPABLE_AD = - new FakeAd(/* skippable= */ false, /* podIndex= */ 0, /* totalAds= */ 1, /* adPosition= */ 1); - @Mock private ImaSdkSettings imaSdkSettings; - @Mock private AdsRenderingSettings adsRenderingSettings; - @Mock private AdDisplayContainer adDisplayContainer; - @Mock private AdsManager adsManager; + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private ImaSdkSettings mockImaSdkSettings; + @Mock private AdsRenderingSettings mockAdsRenderingSettings; + @Mock private AdDisplayContainer mockAdDisplayContainer; + @Mock private AdsManager mockAdsManager; + @Mock private AdsRequest mockAdsRequest; + @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent; + @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader; @Mock private ImaFactory mockImaFactory; + @Mock private AdPodInfo mockPrerollSingleAdAdPodInfo; + @Mock private Ad mockPrerollSingleAd; + private ViewGroup adViewGroup; private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; @@ -93,14 +111,7 @@ public class ImaAdsLoaderTest { @Before public void setUp() { - MockitoAnnotations.initMocks(this); - FakeAdsRequest fakeAdsRequest = new FakeAdsRequest(); - FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(imaSdkSettings, adsManager); - when(mockImaFactory.createAdDisplayContainer()).thenReturn(adDisplayContainer); - when(mockImaFactory.createAdsRenderingSettings()).thenReturn(adsRenderingSettings); - when(mockImaFactory.createAdsRequest()).thenReturn(fakeAdsRequest); - when(mockImaFactory.createImaSdkSettings()).thenReturn(imaSdkSettings); - when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(fakeAdsLoader); + setupMocks(); adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext()); adOverlayView = new View(ApplicationProvider.getApplicationContext()); adViewProvider = @@ -125,24 +136,24 @@ public class ImaAdsLoaderTest { } @Test - public void testBuilder_overridesPlayerType() { - when(imaSdkSettings.getPlayerType()).thenReturn("test player type"); + public void builder_overridesPlayerType() { + when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); - verify(imaSdkSettings).setPlayerType("google/exo.ext.ima"); + verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test - public void testStart_setsAdUiViewGroup() { + public void start_setsAdUiViewGroup() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); - verify(adDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); - verify(adDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView); + verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); + verify(mockAdDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView); } @Test - public void testStart_withPlaceholderContent_initializedAdsLoader() { + public void start_withPlaceholderContent_initializedAdsLoader() { Timeline placeholderTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -152,7 +163,7 @@ public class ImaAdsLoaderTest { } @Test - public void testStart_updatesAdPlaybackState() { + public void start_updatesAdPlaybackState() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -164,14 +175,14 @@ public class ImaAdsLoaderTest { } @Test - public void testStartAfterRelease() { + public void startAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test - public void testStartAndCallbacksAfterRelease() { + public void startAndCallbacksAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -183,11 +194,11 @@ public class ImaAdsLoaderTest { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); imaAdsLoader.loadAd(TEST_URI.toString()); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); imaAdsLoader.playAd(); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); imaAdsLoader.pauseAd(); imaAdsLoader.stopAd(); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); @@ -198,14 +209,14 @@ public class ImaAdsLoaderTest { } @Test - public void testPlayback_withPrerollAd_marksAdAsPlayed() { + public void playback_withPrerollAd_marksAdAsPlayed() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); imaAdsLoader.loadAd(TEST_URI.toString()); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. imaAdsLoader.playAd(); @@ -215,10 +226,10 @@ public class ImaAdsLoaderTest { /* position= */ 0, /* contentPosition= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); // Play the content. fakeExoPlayer.setPlayingContentPosition(0); @@ -238,29 +249,77 @@ public class ImaAdsLoaderTest { } @Test - public void testStop_unregistersAllVideoControlOverlays() { + public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); - InOrder inOrder = inOrder(adDisplayContainer); - inOrder.verify(adDisplayContainer).registerVideoControlsOverlay(adOverlayView); - inOrder.verify(adDisplayContainer).unregisterAllVideoControlsOverlays(); + InOrder inOrder = inOrder(mockAdDisplayContainer); + inOrder.verify(mockAdDisplayContainer).registerVideoControlsOverlay(adOverlayView); + inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) { fakeExoPlayer = new FakePlayer(); adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs); - when(adsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); imaAdsLoader = new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setImaFactory(mockImaFactory) - .setImaSdkSettings(imaSdkSettings) + .setImaSdkSettings(mockImaSdkSettings) .buildForAdTag(TEST_URI); imaAdsLoader.setPlayer(fakeExoPlayer); } + private void setupMocks() { + ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class); + doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture()); + when(mockAdsRequest.getUserRequestContext()) + .thenAnswer((Answer) invocation -> userRequestContextCaptor.getValue()); + List adsLoadedListeners = + new ArrayList<>(); + doAnswer( + invocation -> { + adsLoadedListeners.add(invocation.getArgument(0)); + return null; + }) + .when(mockAdsLoader) + .addAdsLoadedListener(any()); + doAnswer( + invocation -> { + adsLoadedListeners.remove(invocation.getArgument(0)); + return null; + }) + .when(mockAdsLoader) + .removeAdsLoadedListener(any()); + when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); + when(mockAdsManagerLoadedEvent.getUserRequestContext()) + .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); + doAnswer( + (Answer) + invocation -> { + for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : + adsLoadedListeners) { + listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); + } + return null; + }) + .when(mockAdsLoader) + .requestAds(mockAdsRequest); + + when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer); + when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings); + when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); + when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(mockAdsLoader); + + when(mockPrerollSingleAdAdPodInfo.getPodIndex()).thenReturn(0); + when(mockPrerollSingleAdAdPodInfo.getTotalAds()).thenReturn(1); + when(mockPrerollSingleAdAdPodInfo.getAdPosition()).thenReturn(1); + + when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockPrerollSingleAdAdPodInfo); + } + private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { return new AdEvent() { @Override @@ -301,7 +360,8 @@ public class ImaAdsLoaderTest { public void onAdPlaybackState(AdPlaybackState adPlaybackState) { adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); this.adPlaybackState = adPlaybackState; - fakeExoPlayer.updateTimeline(new SinglePeriodAdTimeline(contentTimeline, adPlaybackState)); + fakeExoPlayer.updateTimeline( + new SinglePeriodAdTimeline(contentTimeline, adPlaybackState)); } @Override From de03e389c0927d1e1b76c13b556533048a3daf88 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 29 Apr 2020 08:52:01 +0100 Subject: [PATCH 0378/1052] Enable nullness checking for the IMA extension adPlaybackState is now non-null, and the uninitialized case is covered by a new boolean hasAdPlaybackState. Position progress updates are now non-null and initialized with IMA's VIDEO_TIME_NOT_READY constant. Also fix some misc code issues: - Remove empty branch for SmoothStreaming (Android Studio warns about this). - Tidy onTimelineChanged and onPositionDiscontinuity and the methods they call to improve naming. - Remove logging for IMA events after release, as these methods are expected to be called in the current IMA SDK behavior. PiperOrigin-RevId: 308977116 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 122 ++++++++++-------- .../exoplayer2/source/ads/AdsMediaSource.java | 2 +- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 5d5d156b97..ceb1dd35dc 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.ima; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.content.Context; import android.net.Uri; import android.os.Looper; @@ -282,7 +284,7 @@ public final class ImaAdsLoader * Threshold before the end of content at which IMA is notified that content is complete if the * player buffers, in milliseconds. */ - private static final long END_OF_CONTENT_POSITION_THRESHOLD_MS = 5000; + private static final long END_OF_CONTENT_THRESHOLD_MS = 5000; /** The maximum duration before an ad break that IMA may start preloading the next ad. */ private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000; @@ -325,7 +327,7 @@ public final class ImaAdsLoader private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; - private Object pendingAdRequestContext; + @Nullable private Object pendingAdRequestContext; private List supportedMimeTypes; @Nullable private EventListener eventListener; @Nullable private Player player; @@ -335,7 +337,8 @@ public final class ImaAdsLoader @Nullable private AdsManager adsManager; private boolean initializedAdsManager; - private AdLoadException pendingAdLoadError; + private boolean hasAdPlaybackState; + @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; private long contentDurationMs; private int podIndexOffset; @@ -439,6 +442,7 @@ public final class ImaAdsLoader /* imaFactory= */ new DefaultImaFactory()); } + @SuppressWarnings("nullness:argument.type.incompatible") private ImaAdsLoader( Context context, @Nullable Uri adTagUri, @@ -479,12 +483,16 @@ public final class ImaAdsLoader context.getApplicationContext(), imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(/* adErrorListener= */ this); adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + supportedMimeTypes = Collections.emptyList(); + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; fakeContentProgressOffsetMs = C.TIME_UNSET; pendingContentPositionMs = C.TIME_UNSET; adGroupIndex = C.INDEX_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; + adPlaybackState = AdPlaybackState.NONE; } /** @@ -532,22 +540,22 @@ public final class ImaAdsLoader * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI. */ public void requestAds(ViewGroup adViewGroup) { - if (adPlaybackState != null || adsManager != null || pendingAdRequestContext != null) { + if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) { // Ads have already been requested. return; } adDisplayContainer.setAdContainer(adViewGroup); - pendingAdRequestContext = new Object(); AdsRequest request = imaFactory.createAdsRequest(); if (adTagUri != null) { request.setAdTagUrl(adTagUri.toString()); - } else /* adsResponse != null */ { - request.setAdsResponse(adsResponse); + } else { + request.setAdsResponse(castNonNull(adsResponse)); } if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } request.setContentProgressProvider(this); + pendingAdRequestContext = new Object(); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); } @@ -567,6 +575,7 @@ public final class ImaAdsLoader public void setSupportedContentTypes(@C.ContentType int... contentTypes) { List supportedMimeTypes = new ArrayList<>(); for (@C.ContentType int contentType : contentTypes) { + // IMA does not support Smooth Streaming ad media. if (contentType == C.TYPE_DASH) { supportedMimeTypes.add(MimeTypes.APPLICATION_MPD); } else if (contentType == C.TYPE_HLS) { @@ -579,8 +588,6 @@ public final class ImaAdsLoader MimeTypes.VIDEO_H263, MimeTypes.AUDIO_MP4, MimeTypes.AUDIO_MPEG)); - } else if (contentType == C.TYPE_SS) { - // IMA does not support Smooth Streaming ad media. } } this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes); @@ -594,22 +601,23 @@ public final class ImaAdsLoader if (player == null) { return; } + player.addListener(this); + boolean playWhenReady = player.getPlayWhenReady(); this.eventListener = eventListener; lastVolumePercentage = 0; - lastAdProgress = null; - lastContentProgress = null; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); adDisplayContainer.setAdContainer(adViewGroup); View[] adOverlayViews = adViewProvider.getAdOverlayViews(); for (View view : adOverlayViews) { adDisplayContainer.registerVideoControlsOverlay(view); } - player.addListener(this); maybeNotifyPendingAdLoadError(); - if (adPlaybackState != null) { + if (hasAdPlaybackState) { // Pass the ad playback state to the player, and resume ads if necessary. eventListener.onAdPlaybackState(adPlaybackState); - if (imaPausedContent && player.getPlayWhenReady()) { + if (adsManager != null && imaPausedContent && playWhenReady) { adsManager.resume(); } } else if (adsManager != null) { @@ -623,21 +631,22 @@ public final class ImaAdsLoader @Override public void stop() { + @Nullable Player player = this.player; if (player == null) { return; } if (adsManager != null && imaPausedContent) { + adsManager.pause(); adPlaybackState = adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); - adsManager.pause(); } lastVolumePercentage = getVolume(); lastAdProgress = getAdProgress(); lastContentProgress = getContentProgress(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); - player = null; + this.player = null; eventListener = null; } @@ -659,6 +668,7 @@ public final class ImaAdsLoader imaAdState = IMA_AD_STATE_NONE; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = false; updateAdPlaybackState(); } @@ -694,6 +704,7 @@ public final class ImaAdsLoader // If a player is attached already, start playback immediately. try { adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + hasAdPlaybackState = true; updateAdPlaybackState(); } catch (Exception e) { maybeNotifyInternalError("onAdsManagerLoaded", e); @@ -710,7 +721,7 @@ public final class ImaAdsLoader Log.d(TAG, "onAdEvent: " + adEventType); } if (adsManager == null) { - Log.w(TAG, "Ignoring AdEvent after release: " + adEvent); + // Drop events after release. return; } try { @@ -731,7 +742,8 @@ public final class ImaAdsLoader if (adsManager == null) { // No ads were loaded, so allow playback to start without any ads. pendingAdRequestContext = null; - adPlaybackState = new AdPlaybackState(); + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; updateAdPlaybackState(); } else if (isAdGroupLoadError(error)) { try { @@ -768,7 +780,7 @@ public final class ImaAdsLoader adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { - contentPositionMs = getContentPeriodPositionMs(); + contentPositionMs = getContentPeriodPositionMs(player, timeline, period); // Update the expected ad group index for the current content position. The update is delayed // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered // just after an ad group isn't incorrectly attributed to the next ad group. @@ -808,11 +820,12 @@ public final class ImaAdsLoader @Override public int getVolume() { + @Nullable Player player = this.player; if (player == null) { return lastVolumePercentage; } - Player.AudioComponent audioComponent = player.getAudioComponent(); + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); if (audioComponent != null) { return (int) (audioComponent.getVolume() * 100); } @@ -834,16 +847,16 @@ public final class ImaAdsLoader Log.d(TAG, "loadAd in ad group " + adGroupIndex); } if (adsManager == null) { - Log.w(TAG, "Ignoring loadAd after release"); + // Drop events after release. return; } if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = expectedAdGroupIndex; + adsManager.start(); Log.w( TAG, "Unexpected loadAd without LOADED event; assuming ad group index is actually " + expectedAdGroupIndex); - adGroupIndex = expectedAdGroupIndex; - adsManager.start(); } int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex); if (adIndexInAdGroup == C.INDEX_UNSET) { @@ -874,7 +887,7 @@ public final class ImaAdsLoader Log.d(TAG, "playAd"); } if (adsManager == null) { - Log.w(TAG, "Ignoring playAd after release"); + // Drop events after release. return; } switch (imaAdState) { @@ -911,7 +924,7 @@ public final class ImaAdsLoader // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. Log.w(TAG, "Unexpected playAd while detached"); } else if (!player.getPlayWhenReady()) { - adsManager.pause(); + Assertions.checkNotNull(adsManager).pause(); } } @@ -921,7 +934,7 @@ public final class ImaAdsLoader Log.d(TAG, "stopAd"); } if (adsManager == null) { - Log.w(TAG, "Ignoring stopAd after release"); + // Drop event after release. return; } if (player == null) { @@ -977,9 +990,14 @@ public final class ImaAdsLoader } if (!initializedAdsManager && adsManager != null) { initializedAdsManager = true; - initializeAdsManager(); + initializeAdsManager(adsManager); } - checkForContentCompleteOrNewAdGroup(); + handleTimelineOrPositionChanged(); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + handleTimelineOrPositionChanged(); } @Override @@ -1009,14 +1027,9 @@ public final class ImaAdsLoader } } - @Override - public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { - checkForContentCompleteOrNewAdGroup(); - } - // Internal methods. - private void initializeAdsManager() { + private void initializeAdsManager(AdsManager adsManager) { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes(supportedMimeTypes); @@ -1033,7 +1046,8 @@ public final class ImaAdsLoader // Skip ads based on the start position as required. long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); - long contentPositionMs = getContentPeriodPositionMs(); + long contentPositionMs = + getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); int adGroupIndexForPosition = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); @@ -1086,7 +1100,7 @@ public final class ImaAdsLoader podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset); int adPosition = adPodInfo.getAdPosition(); int adCount = adPodInfo.getTotalAds(); - adsManager.start(); + Assertions.checkNotNull(adsManager).start(); if (DEBUG) { Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex); } @@ -1138,8 +1152,6 @@ public final class ImaAdsLoader handleAdGroupLoadError(new IOException(message)); } break; - case STARTED: - case ALL_ADS_COMPLETED: default: break; } @@ -1160,7 +1172,8 @@ public final class ImaAdsLoader } } - private void checkForContentCompleteOrNewAdGroup() { + private void handleTimelineOrPositionChanged() { + @Nullable Player player = this.player; if (adsManager == null || player == null) { return; } @@ -1174,7 +1187,7 @@ public final class ImaAdsLoader } updateAdPlaybackState(); } else if (!timeline.isEmpty()) { - long positionMs = getContentPeriodPositionMs(); + long positionMs = getContentPeriodPositionMs(player, timeline, period); timeline.getPeriod(/* periodIndex= */ 0, period); int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); if (newAdGroupIndex != C.INDEX_UNSET) { @@ -1186,10 +1199,7 @@ public final class ImaAdsLoader } } } - updateImaStateForPlayerState(); - } - private void updateImaStateForPlayerState() { boolean wasPlayingAd = playingAd; int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; playingAd = player.isPlayingAd(); @@ -1316,10 +1326,11 @@ public final class ImaAdsLoader } private void checkForContentComplete() { - if (contentDurationMs != C.TIME_UNSET + long positionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); + if (!sentContentComplete + && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && getContentPeriodPositionMs() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs - && !sentContentComplete) { + && positionMs + END_OF_CONTENT_THRESHOLD_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { Log.d(TAG, "adsLoader.contentComplete"); @@ -1357,7 +1368,7 @@ public final class ImaAdsLoader private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { - eventListener.onAdLoadError(pendingAdLoadError, new DataSpec(adTagUri)); + eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); pendingAdLoadError = null; } } @@ -1366,22 +1377,23 @@ public final class ImaAdsLoader String message = "Internal error in " + name; Log.e(TAG, message, cause); // We can't recover from an unexpected error in general, so skip all remaining ads. - if (adPlaybackState == null) { - adPlaybackState = AdPlaybackState.NONE; - } else { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } updateAdPlaybackState(); if (eventListener != null) { eventListener.onAdLoadError( AdLoadException.createForUnexpected(new RuntimeException(message, cause)), - new DataSpec(adTagUri)); + getAdsDataSpec(adTagUri)); } } - private long getContentPeriodPositionMs() { + private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) { + return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY); + } + + private static long getContentPeriodPositionMs( + Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); return contentWindowPositionMs - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 4ecef1bd5b..3481042c98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -271,7 +271,7 @@ public final class AdsMediaSource extends CompositeMediaSource { } @Override - protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( MediaPeriodId childId, MediaPeriodId mediaPeriodId) { // The child id for the content period is just DUMMY_CONTENT_MEDIA_PERIOD_ID. That's why we need // to forward the reported mediaPeriodId in this case. From 3b99a84dae94cf90e5335f05b22c77bed9440127 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 5 May 2020 10:09:01 +0100 Subject: [PATCH 0379/1052] Migrate to new IMA preloading APIs issue:#6429 PiperOrigin-RevId: 309906760 --- RELEASENOTES.md | 2 + extensions/ima/build.gradle | 2 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 489 ++++++++++-------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 26 +- 4 files changed, 292 insertions(+), 227 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a82e2c19e8..64e1bdf381 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -52,6 +52,8 @@ ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. +* IMA extension: Upgrade to IMA SDK version 3.18.1, and migrate to new + preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). ### 2.11.4 (2020-04-08) ### diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index e2292aed8f..5b63456f74 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -32,7 +32,7 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.11.3' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.18.1' implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index ceb1dd35dc..d2fe3e621e 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.util.Util.castNonNull; import android.content.Context; import android.net.Uri; +import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.view.View; @@ -26,7 +27,6 @@ import android.view.ViewGroup; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import com.google.ads.interactivemedia.v3.api.Ad; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; import com.google.ads.interactivemedia.v3.api.AdError; import com.google.ads.interactivemedia.v3.api.AdError.AdErrorCode; @@ -45,6 +45,7 @@ import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.UiElement; +import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; @@ -54,7 +55,6 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; -import com.google.android.exoplayer2.source.ads.AdPlaybackState.AdState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -71,6 +71,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -277,6 +278,14 @@ public final class ImaAdsLoader private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima"; private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION; + /** + * Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 100 ms is + * the interval recommended by the IMA documentation. + * + * @see com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback + */ + private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100; + /** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */ private static final long IMA_DURATION_UNSET = -1L; @@ -286,9 +295,6 @@ public final class ImaAdsLoader */ private static final long END_OF_CONTENT_THRESHOLD_MS = 5000; - /** The maximum duration before an ad break that IMA may start preloading the next ad. */ - private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000; - private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -302,11 +308,12 @@ public final class ImaAdsLoader */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd()} and not {@link #pauseAd()}. + * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link + * #pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd()} while playing an ad. + * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; @@ -320,10 +327,12 @@ public final class ImaAdsLoader @Nullable private final AdEventListener adEventListener; private final ImaFactory imaFactory; private final Timeline.Period period; - private final Timeline.Window window; + private final Handler handler; private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final Runnable updateAdProgressRunnable; + private final Map adInfoByAdMediaInfo; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; @@ -341,19 +350,18 @@ public final class ImaAdsLoader @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; private long contentDurationMs; - private int podIndexOffset; private AdPlaybackState adPlaybackState; // Fields tracking IMA's state. - /** The expected ad group index that IMA should load next. */ - private int expectedAdGroupIndex; - /** The index of the current ad group that IMA is loading. */ - private int adGroupIndex; /** Whether IMA has sent an ad event to pause content since the last resume content event. */ private boolean imaPausedContent; /** The current ad playback state. */ private @ImaAdState int imaAdState; + /** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdMediaInfo imaAdMediaInfo; + /** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdInfo imaAdInfo; /** * Whether {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been * called since starting ad playback. @@ -364,20 +372,23 @@ public final class ImaAdsLoader /** Whether the player is playing an ad. */ private boolean playingAd; + /** Whether the player is buffering an ad. */ + private boolean bufferingAd; /** * If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET} * otherwise. */ private int playingAdIndexInAdGroup; /** - * Whether there's a pending ad preparation error which IMA needs to be notified of when it - * transitions from playing content to playing the ad. + * The ad info for a pending ad for which the media failed preparation, or {@code null} if no + * pending ads have failed to prepare. */ - private boolean shouldNotifyAdPrepareError; + @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd()}, stores the value - * of {@link SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to - * determine a fake, increasing content position. {@link C#TIME_UNSET} otherwise. + * If a content period has finished but IMA has not yet called {@link #playAd(AdMediaInfo)}, + * stores the value of {@link SystemClock#elapsedRealtime()} when the content stopped playing. + * This can be used to determine a fake, increasing content position. {@link C#TIME_UNSET} + * otherwise. */ private long fakeContentProgressElapsedRealtimeMs; /** @@ -442,7 +453,7 @@ public final class ImaAdsLoader /* imaFactory= */ new DefaultImaFactory()); } - @SuppressWarnings("nullness:argument.type.incompatible") + @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, @Nullable Uri adTagUri, @@ -474,7 +485,7 @@ public final class ImaAdsLoader imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); - window = new Timeline.Window(); + handler = Util.createHandler(getImaLooper(), /* callback= */ null); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); @@ -483,13 +494,14 @@ public final class ImaAdsLoader context.getApplicationContext(), imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(/* adErrorListener= */ this); adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + updateAdProgressRunnable = this::updateAdProgress; + adInfoByAdMediaInfo = new HashMap<>(); supportedMimeTypes = Collections.emptyList(); lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; fakeContentProgressOffsetMs = C.TIME_UNSET; pendingContentPositionMs = C.TIME_UNSET; - adGroupIndex = C.INDEX_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; adPlaybackState = AdPlaybackState.NONE; @@ -564,9 +576,8 @@ public final class ImaAdsLoader @Override public void setPlayer(@Nullable Player player) { - Assertions.checkState(Looper.getMainLooper() == Looper.myLooper()); - Assertions.checkState( - player == null || player.getApplicationLooper() == Looper.getMainLooper()); + Assertions.checkState(Looper.myLooper() == getImaLooper()); + Assertions.checkState(player == null || player.getApplicationLooper() == getImaLooper()); nextPlayer = player; wasSetPlayerCalled = true; } @@ -642,7 +653,7 @@ public final class ImaAdsLoader playingAd ? C.msToUs(player.getCurrentPosition()) : 0); } lastVolumePercentage = getVolume(); - lastAdProgress = getAdProgress(); + lastAdProgress = getAdVideoProgressUpdate(); lastContentProgress = getContentProgress(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); @@ -666,6 +677,8 @@ public final class ImaAdsLoader adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; + imaAdMediaInfo = null; + imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; hasAdPlaybackState = false; @@ -770,32 +783,11 @@ public final class ImaAdsLoader if (pendingContentPositionMs != C.TIME_UNSET) { sentPendingContentPositionMs = true; contentPositionMs = pendingContentPositionMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { contentPositionMs = getContentPeriodPositionMs(player, timeline, period); - // Update the expected ad group index for the current content position. The update is delayed - // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered - // just after an ad group isn't incorrectly attributed to the next ad group. - int nextAdGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (nextAdGroupIndex != expectedAdGroupIndex && nextAdGroupIndex != C.INDEX_UNSET) { - long nextAdGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[nextAdGroupIndex]); - if (nextAdGroupTimeMs == C.TIME_END_OF_SOURCE) { - nextAdGroupTimeMs = contentDurationMs; - } - if (nextAdGroupTimeMs - contentPositionMs < MAXIMUM_PRELOAD_DURATION_MS) { - expectedAdGroupIndex = nextAdGroupIndex; - } - } } else { return VideoProgressUpdate.VIDEO_TIME_NOT_READY; } @@ -807,15 +799,7 @@ public final class ImaAdsLoader @Override public VideoProgressUpdate getAdProgress() { - if (player == null) { - return lastAdProgress; - } else if (imaAdState != IMA_AD_STATE_NONE && playingAd) { - long adDuration = player.getDuration(); - return adDuration == C.TIME_UNSET ? VideoProgressUpdate.VIDEO_TIME_NOT_READY - : new VideoProgressUpdate(player.getCurrentPosition(), adDuration); - } else { - return VideoProgressUpdate.VIDEO_TIME_NOT_READY; - } + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); } @Override @@ -841,30 +825,37 @@ public final class ImaAdsLoader } @Override - public void loadAd(String adUriString) { + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { try { if (DEBUG) { - Log.d(TAG, "loadAd in ad group " + adGroupIndex); + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); } if (adsManager == null) { // Drop events after release. return; } - if (adGroupIndex == C.INDEX_UNSET) { - adGroupIndex = expectedAdGroupIndex; - adsManager.start(); - Log.w( - TAG, - "Unexpected loadAd without LOADED event; assuming ad group index is actually " - + expectedAdGroupIndex); + int adGroupIndex = getAdGroupIndex(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; } - int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex); - if (adIndexInAdGroup == C.INDEX_UNSET) { - Log.w(TAG, "Unexpected loadAd in an ad group with no remaining unavailable ads"); - return; + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = + adPlaybackState.withAdLoadError( + /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + } } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); adPlaybackState = - adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString)); + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); updateAdPlaybackState(); } catch (Exception e) { maybeNotifyInternalError("loadAd", e); @@ -882,69 +873,62 @@ public final class ImaAdsLoader } @Override - public void playAd() { + public void playAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "playAd"); + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { // Drop events after release. return; } - switch (imaAdState) { - case IMA_AD_STATE_PLAYING: - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028, b/63320878]. - Log.w(TAG, "Unexpected playAd without stopAd"); - break; - case IMA_AD_STATE_NONE: - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(); - } - if (shouldNotifyAdPrepareError) { - shouldNotifyAdPrepareError = false; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); - } - } - break; - case IMA_AD_STATE_PAUSED: - imaAdState = IMA_AD_STATE_PLAYING; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(); - } - break; - default: - throw new IllegalStateException(); + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected playAd while detached"); - } else if (!player.getPlayWhenReady()) { + + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { Assertions.checkNotNull(adsManager).pause(); } } @Override - public void stopAd() { + public void stopAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "stopAd"); + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { // Drop event after release. return; } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected stopAd while detached"); - } - if (imaAdState == IMA_AD_STATE_NONE) { - Log.w(TAG, "Unexpected stopAd"); - return; - } + + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { stopAdInternal(); } catch (Exception e) { @@ -953,26 +937,21 @@ public final class ImaAdsLoader } @Override - public void pauseAd() { + public void pauseAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "pauseAd"); + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); } if (imaAdState == IMA_AD_STATE_NONE) { // This method is called after content is resumed. return; } + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); imaAdState = IMA_AD_STATE_PAUSED; for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(); + adCallbacks.get(i).onPause(adMediaInfo); } } - @Override - public void resumeAd() { - // This method is never called. See [Internal: b/18931719]. - maybeNotifyInternalError("resumeAd", new IllegalStateException("Unexpected call to resumeAd")); - } - // Player.EventListener implementation. @Override @@ -1021,8 +1000,9 @@ public final class ImaAdsLoader @Override public void onPlayerError(ExoPlaybackException error) { if (imaAdState != IMA_AD_STATE_NONE) { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(adMediaInfo); } } } @@ -1064,25 +1044,13 @@ public final class ImaAdsLoader adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); } - // IMA indexes any remaining midroll ad pods from 1. A preroll (if present) has index 0. - // Store an index offset as we want to index all ads (including skipped ones) from 0. - if (adGroupIndexForPosition == 0 && adGroupTimesUs[0] == 0) { - // We are playing a preroll. - podIndexOffset = 0; - } else if (adGroupIndexForPosition == C.INDEX_UNSET) { - // There's no ad to play which means there's no preroll. - podIndexOffset = -1; - } else { - // We are playing a midroll and any ads before it were skipped. - podIndexOffset = adGroupIndexForPosition - 1; - } - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { // Provide the player's initial position to trigger loading and playing the ad. pendingContentPositionMs = contentPositionMs; } adsManager.init(adsRenderingSettings); + adsManager.start(); updateAdPlaybackState(); if (DEBUG) { Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); @@ -1090,39 +1058,32 @@ public final class ImaAdsLoader } private void handleAdEvent(AdEvent adEvent) { - Ad ad = adEvent.getAd(); switch (adEvent.getType()) { - case LOADED: - // The ad position is not always accurate when using preloading. See [Internal: b/62613240]. - AdPodInfo adPodInfo = ad.getAdPodInfo(); - int podIndex = adPodInfo.getPodIndex(); - adGroupIndex = - podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset); - int adPosition = adPodInfo.getAdPosition(); - int adCount = adPodInfo.getTotalAds(); - Assertions.checkNotNull(adsManager).start(); + case AD_BREAK_FETCH_ERROR: + String adGroupTimeSecondsString = + Assertions.checkNotNull(adEvent.getAdData().get("adBreakTime")); if (DEBUG) { - Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex); + Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); } - int oldAdCount = adPlaybackState.adGroups[adGroupIndex].count; - if (adCount != oldAdCount) { - if (oldAdCount == C.LENGTH_UNSET) { - adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount); - updateAdPlaybackState(); - } else { - // IMA sometimes unexpectedly decreases the ad count in an ad group. - Log.w(TAG, "Unexpected ad count in LOADED, " + adCount + ", expected " + oldAdCount); + int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); + int adGroupIndex = + Arrays.binarySearch( + adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adGroupIndex]; + } + for (int i = 0; i < adGroup.count; i++) { + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + if (DEBUG) { + Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); } } - if (adGroupIndex != expectedAdGroupIndex) { - Log.w( - TAG, - "Expected ad group index " - + expectedAdGroupIndex - + ", actual ad group index " - + adGroupIndex); - expectedAdGroupIndex = adGroupIndex; - } + updateAdPlaybackState(); break; case CONTENT_PAUSE_REQUESTED: // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads @@ -1148,23 +1109,65 @@ public final class ImaAdsLoader Map adData = adEvent.getAdData(); String message = "AdEvent: " + adData; Log.i(TAG, message); - if ("adLoadError".equals(adData.get("type"))) { - handleAdGroupLoadError(new IOException(message)); - } break; default: break; } } + private VideoProgressUpdate getAdVideoProgressUpdate() { + if (player == null) { + return lastAdProgress; + } else if (imaAdState != IMA_AD_STATE_NONE && playingAd) { + long adDuration = player.getDuration(); + return adDuration == C.TIME_UNSET + ? VideoProgressUpdate.VIDEO_TIME_NOT_READY + : new VideoProgressUpdate(player.getCurrentPosition(), adDuration); + } else { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + } + + private void updateAdProgress() { + VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate(); + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate); + } + handler.removeCallbacks(updateAdProgressRunnable); + handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS); + } + + private void stopUpdatingAdProgress() { + handler.removeCallbacks(updateAdProgressRunnable); + } + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { + if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onBuffering(adMediaInfo); + } + stopUpdatingAdProgress(); + } else if (bufferingAd && playbackState == Player.STATE_READY) { + bufferingAd = false; + updateAdProgress(); + } + } + if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING && playWhenReady) { checkForContentComplete(); } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } } if (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlayerStateChanged"); @@ -1193,9 +1196,6 @@ public final class ImaAdsLoader if (newAdGroupIndex != C.INDEX_UNSET) { sentPendingContentPositionMs = false; pendingContentPositionMs = positionMs; - if (newAdGroupIndex != adGroupIndex) { - shouldNotifyAdPrepareError = false; - } } } } @@ -1208,8 +1208,13 @@ public final class ImaAdsLoader if (adFinished) { // IMA is waiting for the ad playback to finish so invoke the callback now. // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again. - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } } if (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); @@ -1227,15 +1232,8 @@ public final class ImaAdsLoader } private void resumeContentInternal() { - if (imaAdState != IMA_AD_STATE_NONE) { - imaAdState = IMA_AD_STATE_NONE; - if (DEBUG) { - Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd"); - } - } - if (adGroupIndex != C.INDEX_UNSET) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex); - adGroupIndex = C.INDEX_UNSET; + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); updateAdPlaybackState(); } } @@ -1250,23 +1248,40 @@ public final class ImaAdsLoader private void stopAdInternal() { imaAdState = IMA_AD_STATE_NONE; - int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); + stopUpdatingAdProgress(); // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + Assertions.checkNotNull(imaAdInfo); + int adGroupIndex = imaAdInfo.adGroupIndex; + int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); if (!playingAd) { - adGroupIndex = C.INDEX_UNSET; + imaAdMediaInfo = null; + imaAdInfo = null; } } private void handleAdGroupLoadError(Exception error) { - int adGroupIndex = - this.adGroupIndex == C.INDEX_UNSET ? expectedAdGroupIndex : this.adGroupIndex; - if (adGroupIndex == C.INDEX_UNSET) { - // Drop the error, as we don't know which ad group it relates to. + if (player == null) { return; } + + // TODO: Once IMA signals which ad group failed to load, clean up this code. + long playerPositionMs = player.getContentPosition(); + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + // The error doesn't seem to relate to any ad group so give up handling it. + return; + } + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = @@ -1306,19 +1321,20 @@ public final class ImaAdsLoader if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { fakeContentProgressOffsetMs = contentDurationMs; } - shouldNotifyAdPrepareError = true; + pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); } else { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); // We're already playing an ad. if (adIndexInAdGroup > playingAdIndexInAdGroup) { // Mark the playing ad as ended so we can notify the error on the next ad and remove it, // which means that the ad after will load (if any). for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + adCallbacks.get(i).onEnded(adMediaInfo); } } playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(Assertions.checkNotNull(adMediaInfo)); } } adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); @@ -1336,11 +1352,6 @@ public final class ImaAdsLoader Log.d(TAG, "adsLoader.contentComplete"); } sentContentComplete = true; - // After sending content complete IMA will not poll the content position, so set the expected - // ad group index. - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentDurationMs), C.msToUs(contentDurationMs)); } } @@ -1351,21 +1362,6 @@ public final class ImaAdsLoader } } - /** - * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all - * ads in the ad group have loaded. - */ - private int getAdIndexInAdGroupToLoad(int adGroupIndex) { - @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states; - int adIndexInAdGroup = 0; - // IMA loads ads in order. - while (adIndexInAdGroup < states.length - && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) { - adIndexInAdGroup++; - } - return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup; - } - private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); @@ -1399,6 +1395,22 @@ public final class ImaAdsLoader - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); } + private int getAdGroupIndex(AdPodInfo adPodInfo) { + if (adPodInfo.getPodIndex() == -1) { + // This is a postroll ad. + return adPlaybackState.adGroupCount - 1; + } + + // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. + long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) { + return adGroupIndex; + } + } + throw new IllegalStateException("Failed to find cue point"); + } + private static long[] getAdGroupTimesUs(List cuePoints) { if (cuePoints.isEmpty()) { // If no cue points are specified, there is a preroll ad. @@ -1428,6 +1440,12 @@ public final class ImaAdsLoader || adError.getErrorCode() == AdErrorCode.UNKNOWN_ERROR; } + private static Looper getImaLooper() { + // IMA SDK callbacks occur on the main thread. This method can be used to check that the player + // is using the same looper, to ensure all interaction with this class is on the main thread. + return Looper.getMainLooper(); + } + private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { int count = adGroupTimesUs.length; if (count == 1) { @@ -1456,6 +1474,49 @@ public final class ImaAdsLoader Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; + } + + // TODO: Consider moving this into AdPlaybackState. + private static final class AdInfo { + public final int adGroupIndex; + public final int adIndexInAdGroup; + + public AdInfo(int adGroupIndex, int adIndexInAdGroup) { + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdInfo adInfo = (AdInfo) o; + if (adGroupIndex != adInfo.adGroupIndex) { + return false; + } + return adIndexInAdGroup == adInfo.adIndexInAdGroup; + } + + @Override + public int hashCode() { + int result = adGroupIndex; + result = 31 * result + adIndexInAdGroup; + return result; + } + + @Override + public String toString() { + return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')'; + } + } + /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */ private static final class DefaultImaFactory implements ImaFactory { @Override diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index d50fff5ae8..ddcd1ae483 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -41,6 +41,7 @@ import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Player; @@ -85,6 +86,7 @@ public final class ImaAdsLoaderTest { private static final long CONTENT_PERIOD_DURATION_US = CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.EMPTY; + private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @@ -99,7 +101,7 @@ public final class ImaAdsLoaderTest { @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent; @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader; @Mock private ImaFactory mockImaFactory; - @Mock private AdPodInfo mockPrerollSingleAdAdPodInfo; + @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; private ViewGroup adViewGroup; @@ -195,12 +197,12 @@ public final class ImaAdsLoaderTest { // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_URI.toString()); + imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); - imaAdsLoader.playAd(); + imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.pauseAd(); - imaAdsLoader.stopAd(); + imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); + imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); @@ -215,11 +217,11 @@ public final class ImaAdsLoaderTest { // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_URI.toString()); + imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(); + imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, @@ -233,7 +235,7 @@ public final class ImaAdsLoaderTest { // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(); + imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. @@ -313,11 +315,11 @@ public final class ImaAdsLoaderTest { when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(mockAdsLoader); - when(mockPrerollSingleAdAdPodInfo.getPodIndex()).thenReturn(0); - when(mockPrerollSingleAdAdPodInfo.getTotalAds()).thenReturn(1); - when(mockPrerollSingleAdAdPodInfo.getAdPosition()).thenReturn(1); + when(mockAdPodInfo.getPodIndex()).thenReturn(0); + when(mockAdPodInfo.getTotalAds()).thenReturn(1); + when(mockAdPodInfo.getAdPosition()).thenReturn(1); - when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockPrerollSingleAdAdPodInfo); + when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo); } private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { From 8c108fb5fd402320b4cbfeac59173d6a4214a441 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 11 May 2020 12:00:14 +0100 Subject: [PATCH 0380/1052] Upgrade IMA SDK to 3.18.2 PiperOrigin-RevId: 310883076 --- RELEASENOTES.md | 2 +- extensions/ima/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 64e1bdf381..c81782c46d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -52,7 +52,7 @@ ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. -* IMA extension: Upgrade to IMA SDK version 3.18.1, and migrate to new +* IMA extension: Upgrade to IMA SDK version 3.18.2, and migrate to new preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). ### 2.11.4 (2020-04-08) ### diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index 5b63456f74..af2c407d03 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -32,7 +32,7 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.18.1' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.18.2' implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' From 5529c124fee75745e361f6e6294f1ebeafe981e6 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 11 May 2020 14:43:05 +0100 Subject: [PATCH 0381/1052] Remove deprecated symbols in ImaAdsLoader PiperOrigin-RevId: 310901647 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index d2fe3e621e..a2afa3c873 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -41,7 +41,6 @@ import com.google.ads.interactivemedia.v3.api.AdsManager; import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.UiElement; @@ -69,7 +68,6 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -426,33 +424,6 @@ public final class ImaAdsLoader /* imaFactory= */ new DefaultImaFactory()); } - /** - * Creates a new IMA ads loader. - * - * @param context The context. - * @param adTagUri The {@link Uri} of an ad tag compatible with the Android IMA SDK. See - * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for - * more information. - * @param imaSdkSettings {@link ImaSdkSettings} used to configure the IMA SDK, or {@code null} to - * use the default settings. If set, the player type and version fields may be overwritten. - * @deprecated Use {@link ImaAdsLoader.Builder}. - */ - @Deprecated - public ImaAdsLoader(Context context, Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings) { - this( - context, - adTagUri, - imaSdkSettings, - /* adsResponse= */ null, - /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaBitrate= */ BITRATE_UNSET, - /* focusSkipButtonWhenAvailable= */ true, - /* adUiElements= */ null, - /* adEventListener= */ null, - /* imaFactory= */ new DefaultImaFactory()); - } - @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, @@ -529,19 +500,6 @@ public final class ImaAdsLoader return adDisplayContainer; } - /** - * Sets the slots for displaying companion ads. Individual slots can be created using {@link - * ImaSdkFactory#createCompanionAdSlot()}. - * - * @param companionSlots Slots for displaying companion ads. - * @see AdDisplayContainer#setCompanionSlots(Collection) - * @deprecated Use {@code getAdDisplayContainer().setCompanionSlots(...)}. - */ - @Deprecated - public void setCompanionSlots(Collection companionSlots) { - adDisplayContainer.setCompanionSlots(companionSlots); - } - /** * Requests ads, if they have not already been requested. Must be called on the main thread. * From 929b60e209ae975f0bb24a65a3f2e47d498c00f4 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 12 May 2020 08:52:15 +0100 Subject: [PATCH 0382/1052] Improve DEBUG logging in ImaAdsLoader Log content progress events, as these are helpful to debug triggering of events based on the content progress. Don't log AD_PROGRESS events as they occur several times per second while ads are playing, and the verbosity makes logs difficult to read. PiperOrigin-RevId: 311077302 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index a2afa3c873..84bdc8e8a3 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -688,7 +688,7 @@ public final class ImaAdsLoader @Override public void onAdEvent(AdEvent adEvent) { AdEventType adEventType = adEvent.getType(); - if (DEBUG) { + if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { Log.d(TAG, "onAdEvent: " + adEventType); } if (adsManager == null) { @@ -733,24 +733,11 @@ public final class ImaAdsLoader @Override public VideoProgressUpdate getContentProgress() { - if (player == null) { - return lastContentProgress; + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (DEBUG) { + Log.d(TAG, "Content progress: " + videoProgressUpdate); } - boolean hasContentDuration = contentDurationMs != C.TIME_UNSET; - long contentPositionMs; - if (pendingContentPositionMs != C.TIME_UNSET) { - sentPendingContentPositionMs = true; - contentPositionMs = pendingContentPositionMs; - } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { - long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; - contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; - } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { - contentPositionMs = getContentPeriodPositionMs(player, timeline, period); - } else { - return VideoProgressUpdate.VIDEO_TIME_NOT_READY; - } - long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET; - return new VideoProgressUpdate(contentPositionMs, contentDurationMs); + return videoProgressUpdate; } // VideoAdPlayer implementation. @@ -1073,6 +1060,27 @@ public final class ImaAdsLoader } } + private VideoProgressUpdate getContentVideoProgressUpdate() { + if (player == null) { + return lastContentProgress; + } + boolean hasContentDuration = contentDurationMs != C.TIME_UNSET; + long contentPositionMs; + if (pendingContentPositionMs != C.TIME_UNSET) { + sentPendingContentPositionMs = true; + contentPositionMs = pendingContentPositionMs; + } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { + long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; + contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; + } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { + contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + } else { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET; + return new VideoProgressUpdate(contentPositionMs, contentDurationMs); + } + private VideoProgressUpdate getAdVideoProgressUpdate() { if (player == null) { return lastAdProgress; From e482aa2be8388cefc3553ad6b0c2d32af4076d99 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 12 May 2020 08:55:27 +0100 Subject: [PATCH 0383/1052] Fix method ordering in ImaAdsLoader Put static methods at the end. Also add a couple of missing parameter name comments. PiperOrigin-RevId: 311077684 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 84bdc8e8a3..0b9a8d747b 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -237,7 +237,7 @@ public final class ImaAdsLoader context, adTagUri, imaSdkSettings, - null, + /* adsResponse= */ null, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -257,7 +257,7 @@ public final class ImaAdsLoader public ImaAdsLoader buildForAdsResponse(String adsResponse) { return new ImaAdsLoader( context, - null, + /* adTagUri= */ null, imaSdkSettings, adsResponse, vastLoadTimeoutMs, @@ -1350,17 +1350,6 @@ public final class ImaAdsLoader } } - private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) { - return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY); - } - - private static long getContentPeriodPositionMs( - Player player, Timeline timeline, Timeline.Period period) { - long contentWindowPositionMs = player.getContentPosition(); - return contentWindowPositionMs - - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); - } - private int getAdGroupIndex(AdPodInfo adPodInfo) { if (adPodInfo.getPodIndex() == -1) { // This is a postroll ad. @@ -1377,6 +1366,22 @@ public final class ImaAdsLoader throw new IllegalStateException("Failed to find cue point"); } + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; + } + + private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) { + return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY); + } + + private static long getContentPeriodPositionMs( + Player player, Timeline timeline, Timeline.Period period) { + long contentWindowPositionMs = player.getContentPosition(); + return contentWindowPositionMs + - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); + } + private static long[] getAdGroupTimesUs(List cuePoints) { if (cuePoints.isEmpty()) { // If no cue points are specified, there is a preroll ad. @@ -1440,11 +1445,6 @@ public final class ImaAdsLoader Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } - private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { - @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); - return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; - } - // TODO: Consider moving this into AdPlaybackState. private static final class AdInfo { public final int adGroupIndex; From e8c74055451a65b7db11e4bf7e95c919cc7da0d6 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 12 May 2020 13:35:57 +0100 Subject: [PATCH 0384/1052] Upgrade IMA SDK to 3.19.0 PiperOrigin-RevId: 311106612 --- RELEASENOTES.md | 2 +- extensions/ima/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c81782c46d..ab7e9998c5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -52,7 +52,7 @@ ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. -* IMA extension: Upgrade to IMA SDK version 3.18.2, and migrate to new +* IMA extension: Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). ### 2.11.4 (2020-04-08) ### diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index af2c407d03..2ed44f638c 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -32,7 +32,7 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.18.2' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.19.0' implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' From 51b2a0f7a9db5fd8b0cc2ca8dd58a5da9e6e6aae Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 15 May 2020 16:00:00 +0100 Subject: [PATCH 0385/1052] Add support for timing out ad preloading Detect stuck buffering cases in ImaAdsLoader, and discard the ad group after a timeout. This is intended to make the IMA extension more robust in the case where an ad group unexpectedly doesn't load. The timing out behavior is enabled by default but apps can choose to retain the old behavior by setting an unset timeout on ImaAdsLoader.Builder. PiperOrigin-RevId: 311729798 --- RELEASENOTES.md | 8 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 142 +++++++++++++++--- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 114 ++++++++++---- .../source/ads/AdPlaybackState.java | 12 ++ .../source/ads/AdPlaybackStateTest.java | 2 + 5 files changed, 228 insertions(+), 50 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ab7e9998c5..a2a439c52f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -52,8 +52,12 @@ ([#7234](https://github.com/google/ExoPlayer/issues/7234)). * AV1 extension: Add a heuristic to determine the default number of threads used for AV1 playback using the extension. -* IMA extension: Upgrade to IMA SDK version 3.19.0, and migrate to new - preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). +* IMA extension: + * Upgrade to IMA SDK version 3.19.0, and migrate to new + preloading APIs + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). + * Add support for timing out ad preloading, to avoid playback getting + stuck if an ad group unexpectedly fails to load. ### 2.11.4 (2020-04-08) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 0b9a8d747b..b151a595c0 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -102,11 +102,23 @@ public final class ImaAdsLoader /** Builder for {@link ImaAdsLoader}. */ public static final class Builder { + /** + * The default duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. + * + *

      This value should be large enough not to trigger discarding the ad when it actually might + * load soon, but small enough so that user is not waiting for too long. + * + * @see #setAdPreloadTimeoutMs(long) + */ + public static final long DEFAULT_AD_PRELOAD_TIMEOUT_MS = 10 * C.MILLIS_PER_SECOND; + private final Context context; @Nullable private ImaSdkSettings imaSdkSettings; @Nullable private AdEventListener adEventListener; @Nullable private Set adUiElements; + private long adPreloadTimeoutMs; private int vastLoadTimeoutMs; private int mediaLoadTimeoutMs; private int mediaBitrate; @@ -120,6 +132,7 @@ public final class ImaAdsLoader */ public Builder(Context context) { this.context = Assertions.checkNotNull(context); + adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS; vastLoadTimeoutMs = TIMEOUT_UNSET; mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; @@ -165,6 +178,25 @@ public final class ImaAdsLoader return this; } + /** + * Sets the duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. Pass {@link + * C#TIME_UNSET} if there should be no such timeout. The default value is {@value + * DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms. + * + *

      The purpose of this timeout is to avoid playback getting stuck in the unexpected case that + * the IMA SDK does not load an ad break based on the player's reported content position. + * + * @param adPreloadTimeoutMs The timeout buffering duration in milliseconds, or {@link + * C#TIME_UNSET} for no timeout. + * @return This builder, for convenience. + */ + public Builder setAdPreloadTimeoutMs(long adPreloadTimeoutMs) { + Assertions.checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0); + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + return this; + } + /** * Sets the VAST load timeout, in milliseconds. * @@ -238,6 +270,7 @@ public final class ImaAdsLoader adTagUri, imaSdkSettings, /* adsResponse= */ null, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -260,6 +293,7 @@ public final class ImaAdsLoader /* adTagUri= */ null, imaSdkSettings, adsResponse, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -291,7 +325,12 @@ public final class ImaAdsLoader * Threshold before the end of content at which IMA is notified that content is complete if the * player buffers, in milliseconds. */ - private static final long END_OF_CONTENT_THRESHOLD_MS = 5000; + private static final long THRESHOLD_END_OF_CONTENT_MS = 5000; + /** + * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in + * milliseconds. + */ + private static final long THRESHOLD_AD_PRELOAD_MS = 4000; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -317,6 +356,7 @@ public final class ImaAdsLoader @Nullable private final Uri adTagUri; @Nullable private final String adsResponse; + private final long adPreloadTimeoutMs; private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; @@ -398,6 +438,11 @@ public final class ImaAdsLoader private long pendingContentPositionMs; /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ private boolean sentPendingContentPositionMs; + /** + * Stores the real time in milliseconds at which the player started buffering, possibly due to not + * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. + */ + private long waitingForPreloadElapsedRealtimeMs; /** * Creates a new IMA ads loader. @@ -415,6 +460,7 @@ public final class ImaAdsLoader adTagUri, /* imaSdkSettings= */ null, /* adsResponse= */ null, + /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS, /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, @@ -430,6 +476,7 @@ public final class ImaAdsLoader @Nullable Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings, @Nullable String adsResponse, + long adPreloadTimeoutMs, int vastLoadTimeoutMs, int mediaLoadTimeoutMs, int mediaBitrate, @@ -440,6 +487,7 @@ public final class ImaAdsLoader Assertions.checkArgument(adTagUri != null || adsResponse != null); this.adTagUri = adTagUri; this.adsResponse = adsResponse; + this.adPreloadTimeoutMs = adPreloadTimeoutMs; this.vastLoadTimeoutMs = vastLoadTimeoutMs; this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; @@ -473,6 +521,7 @@ public final class ImaAdsLoader fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; fakeContentProgressOffsetMs = C.TIME_UNSET; pendingContentPositionMs = C.TIME_UNSET; + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; adPlaybackState = AdPlaybackState.NONE; @@ -636,6 +685,7 @@ public final class ImaAdsLoader imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; + stopUpdatingAdProgress(); imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; @@ -737,6 +787,19 @@ public final class ImaAdsLoader if (DEBUG) { Log.d(TAG, "Content progress: " + videoProgressUpdate); } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } + return videoProgressUpdate; } @@ -779,10 +842,15 @@ public final class ImaAdsLoader // Drop events after release. return; } - int adGroupIndex = getAdGroupIndex(adPodInfo); + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA will + // timeout after its media load timeout. + return; + } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = @@ -926,10 +994,34 @@ public final class ImaAdsLoader @Override public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + @Nullable Player player = this.player; if (adsManager == null || player == null) { return; } + if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) { + // Check whether we are waiting for an ad to preload. + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count != C.LENGTH_UNSET + && adGroup.count != 0 + && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + // An ad is available already so we must be buffering for some other reason. + return; + } + long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + long timeUntilAdMs = adGroupTimeMs - contentPositionMs; + if (timeUntilAdMs < adPreloadTimeoutMs) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { adsManager.pause(); return; @@ -939,6 +1031,7 @@ public final class ImaAdsLoader adsManager.resume(); return; } + handlePlayerStateChanged(playWhenReady, playbackState); } @@ -1219,6 +1312,10 @@ public final class ImaAdsLoader Assertions.checkNotNull(imaAdInfo); int adGroupIndex = imaAdInfo.adGroupIndex; int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. + return; + } adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); @@ -1233,19 +1330,11 @@ public final class ImaAdsLoader return; } - // TODO: Once IMA signals which ad group failed to load, clean up this code. - long playerPositionMs = player.getContentPosition(); - int adGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + // TODO: Once IMA signals which ad group failed to load, remove this call. + int adGroupIndex = getLoadingAdGroupIndex(); if (adGroupIndex == C.INDEX_UNSET) { - adGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndex == C.INDEX_UNSET) { - // The error doesn't seem to relate to any ad group so give up handling it. - return; - } + Log.w(TAG, "Unable to determine ad group index for ad group load error", error); + return; } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; @@ -1312,7 +1401,7 @@ public final class ImaAdsLoader if (!sentContentComplete && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && positionMs + END_OF_CONTENT_THRESHOLD_MS >= contentDurationMs) { + && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { Log.d(TAG, "adsLoader.contentComplete"); @@ -1350,7 +1439,7 @@ public final class ImaAdsLoader } } - private int getAdGroupIndex(AdPodInfo adPodInfo) { + private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { if (adPodInfo.getPodIndex() == -1) { // This is a postroll ad. return adPlaybackState.adGroupCount - 1; @@ -1366,6 +1455,23 @@ public final class ImaAdsLoader throw new IllegalStateException("Failed to find cue point"); } + /** + * Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is + * no such ad group. + */ + private int getLoadingAdGroupIndex() { + long playerPositionUs = + C.msToUs(getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period)); + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + playerPositionUs, C.msToUs(contentDurationMs)); + } + return adGroupIndex; + } + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; @@ -1379,7 +1485,9 @@ public final class ImaAdsLoader Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); return contentWindowPositionMs - - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } private static long[] getAdGroupTimesUs(List cuePoints) { diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index ddcd1ae483..18515f0625 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -48,7 +48,7 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.ext.ima.ImaAdsLoader.ImaFactory; -import com.google.android.exoplayer2.source.MaskingMediaSource; +import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; @@ -57,6 +57,7 @@ import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -73,22 +74,23 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.mockito.stubbing.Answer; +import org.robolectric.shadows.ShadowSystemClock; -/** Test for {@link ImaAdsLoader}. */ +/** Tests for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) public final class ImaAdsLoaderTest { private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final Timeline CONTENT_TIMELINE = new FakeTimeline( - new FakeTimeline.TimelineWindowDefinition( + new TimelineWindowDefinition( /* isSeekable= */ true, /* isDynamic= */ false, CONTENT_DURATION_US)); private static final long CONTENT_PERIOD_DURATION_US = CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; + private static final long[][] ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -140,14 +142,14 @@ public final class ImaAdsLoaderTest { @Test public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test public void start_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); @@ -156,8 +158,8 @@ public final class ImaAdsLoaderTest { @Test public void start_withPlaceholderContent_initializedAdsLoader() { - Timeline placeholderTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); - setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); + setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); // We'll only create the rendering settings when initializing the ads loader. @@ -166,26 +168,26 @@ public final class ImaAdsLoaderTest { @Test public void start_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ 0) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void startAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test public void startAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -212,7 +214,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -245,14 +247,64 @@ public final class ImaAdsLoaderTest { .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance before the timeout and simulating polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US)); + } + + @Test + public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance past the timeout and simulate polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); @@ -282,31 +334,31 @@ public final class ImaAdsLoaderTest { List adsLoadedListeners = new ArrayList<>(); doAnswer( - invocation -> { - adsLoadedListeners.add(invocation.getArgument(0)); - return null; - }) + invocation -> { + adsLoadedListeners.add(invocation.getArgument(0)); + return null; + }) .when(mockAdsLoader) .addAdsLoadedListener(any()); doAnswer( - invocation -> { - adsLoadedListeners.remove(invocation.getArgument(0)); - return null; - }) + invocation -> { + adsLoadedListeners.remove(invocation.getArgument(0)); + return null; + }) .when(mockAdsLoader) .removeAdsLoadedListener(any()); when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); when(mockAdsManagerLoadedEvent.getUserRequestContext()) .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); doAnswer( - (Answer) - invocation -> { - for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : - adsLoadedListeners) { - listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); - } - return null; - }) + (Answer) + invocation -> { + for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : + adsLoadedListeners) { + listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); + } + return null; + }) .when(mockAdsLoader) .requestAds(mockAdsRequest); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index dee63d819e..783a452b1a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -360,6 +360,18 @@ public final class AdPlaybackState { return index < adGroupTimesUs.length ? index : C.INDEX_UNSET; } + /** Returns whether the specified ad has been marked as in {@link #AD_STATE_ERROR}. */ + public boolean isAdInErrorState(int adGroupIndex, int adIndexInAdGroup) { + if (adGroupIndex >= adGroups.length) { + return false; + } + AdGroup adGroup = adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET || adIndexInAdGroup >= adGroup.count) { + return false; + } + return adGroup.states[adIndexInAdGroup] == AdPlaybackState.AD_STATE_ERROR; + } + /** * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. * The ad count must be greater than zero. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 0cd27a90c0..bd4dd8876f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -64,7 +64,9 @@ public final class AdPlaybackStateTest { assertThat(state.adGroups[0].uris[0]).isNull(); assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)).isTrue(); assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } @Test From 51a3f214ed6c5bd49ef1a43470399cfd01a40f49 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 14:05:47 +0100 Subject: [PATCH 0386/1052] Fix handling of fetch errors for post-rolls The ad break time in seconds from IMA was "-1" for postrolls, but this didn't match C.TIME_END_OF_SOURCE in the ad group times array. Handle an ad break time of -1 directly by mapping it onto the last ad group, instead of trying to look it up in the array. PiperOrigin-RevId: 312064886 --- extensions/ima/build.gradle | 1 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 6 +++-- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 23 +++++++++++++++++++ .../google/android/exoplayer2/util/Util.java | 18 +++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index 2ed44f638c..b83caf62ee 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' testImplementation project(modulePrefix + 'testutils') + testImplementation 'com.google.guava:guava:' + guavaVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index b151a595c0..19109d9c04 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1105,8 +1105,10 @@ public final class ImaAdsLoader } int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); int adGroupIndex = - Arrays.binarySearch( - adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); + adGroupTimeSeconds == -1 + ? adPlaybackState.adGroupCount - 1 + : Util.linearSearch( + adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 18515f0625..804434b835 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -56,6 +56,7 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; @@ -105,6 +106,7 @@ public final class ImaAdsLoaderTest { @Mock private ImaFactory mockImaFactory; @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; + @Mock private AdEvent mockPostrollFetchErrorAdEvent; private ViewGroup adViewGroup; private View adOverlayView; @@ -252,6 +254,23 @@ public final class ImaAdsLoaderTest { .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withPostrollFetchError_marksAdAsInErrorState() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, new Float[] {-1f}); + + // Simulate loading an empty postroll ad. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.onAdEvent(mockPostrollFetchErrorAdEvent); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + @Test public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { // Simulate an ad at 2 seconds. @@ -372,6 +391,10 @@ public final class ImaAdsLoaderTest { when(mockAdPodInfo.getAdPosition()).thenReturn(1); when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo); + + when(mockPostrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockPostrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "-1")); } private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index ea43ee7bb3..a7a46b163d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -726,6 +726,24 @@ public final class Util { return C.INDEX_UNSET; } + /** + * Returns the index of the first occurrence of {@code value} in {@code array}, or {@link + * C#INDEX_UNSET} if {@code value} is not contained in {@code array}. + * + * @param array The array to search. + * @param value The value to search for. + * @return The index of the first occurrence of value in {@code array}, or {@link C#INDEX_UNSET} + * if {@code value} is not contained in {@code array}. + */ + public static int linearSearch(long[] array, long value) { + for (int i = 0; i < array.length; i++) { + if (array[i] == value) { + return i; + } + } + return C.INDEX_UNSET; + } + /** * Returns the index of the largest element in {@code array} that is less than (or optionally * equal to) a specified {@code value}. From 35a705e92a278a70becfbb9c9c7197eded4c0069 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 16:10:17 +0100 Subject: [PATCH 0387/1052] Add release notes for issues fixed by preloading migration PiperOrigin-RevId: 312080838 --- RELEASENOTES.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a2a439c52f..25acc4f022 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -55,9 +55,23 @@ * IMA extension: * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs - ([#6429](https://github.com/google/ExoPlayer/issues/6429)). + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes + several issues involving preloading and handling of ad loading error + cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), + [#5006](https://github.com/google/ExoPlayer/issues/5006), + [#6030](https://github.com/google/ExoPlayer/issues/6030), + [#6097](https://github.com/google/ExoPlayer/issues/6097), + [#6425](https://github.com/google/ExoPlayer/issues/6425), + [#6967](https://github.com/google/ExoPlayer/issues/6967), + [#7041](https://github.com/google/ExoPlayer/issues/7041), + [#7161](https://github.com/google/ExoPlayer/issues/7161), + [#7212](https://github.com/google/ExoPlayer/issues/7212), + [#7340](https://github.com/google/ExoPlayer/issues/7340)). * Add support for timing out ad preloading, to avoid playback getting - stuck if an ad group unexpectedly fails to load. + stuck if an ad group unexpectedly fails to load + ([#5444](https://github.com/google/ExoPlayer/issues/5444), + [#5966](https://github.com/google/ExoPlayer/issues/5966) + [#7002](https://github.com/google/ExoPlayer/issues/7002)). ### 2.11.4 (2020-04-08) ### From b5e5b55ef850a30df3a73a7c4e4a449313b52fde Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 18 May 2020 16:30:12 +0100 Subject: [PATCH 0388/1052] Fix typo PiperOrigin-RevId: 312083761 --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 25acc4f022..370dd47775 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -70,7 +70,7 @@ * Add support for timing out ad preloading, to avoid playback getting stuck if an ad group unexpectedly fails to load ([#5444](https://github.com/google/ExoPlayer/issues/5444), - [#5966](https://github.com/google/ExoPlayer/issues/5966) + [#5966](https://github.com/google/ExoPlayer/issues/5966), [#7002](https://github.com/google/ExoPlayer/issues/7002)). ### 2.11.4 (2020-04-08) ### From a1ebffd238811e51244a6eb66a064529878d84ba Mon Sep 17 00:00:00 2001 From: Arnold Szabo Date: Sun, 31 May 2020 23:40:10 +0300 Subject: [PATCH 0389/1052] Correct tie-breaking rules when selecting fixed video track --- .../trackselection/DefaultTrackSelector.java | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index d4ef9f54fc..4fe065973b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -1498,6 +1498,14 @@ public class DefaultTrackSelector extends MappingTrackSelector { private static final float FRACTION_TO_CONSIDER_FULLSCREEN = 0.98f; private static final int[] NO_TRACKS = new int[0]; private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; + /** + * In case when both min and max constraints are set, give the track higher score which satisfies + * the max constraints, as it is more important to not violate the max constraints than min + * constraints, because it's more likely to play successfully when max constraints are satisfied. + */ + private static final int SATISFIES_MIN_CONSTRAINTS_BONUS = 1; + private static final int SATISFIES_MAX_CONSTRAINTS_BONUS = 2; + private final TrackSelection.Factory trackSelectionFactory; private final AtomicReference parametersReference; @@ -2040,8 +2048,6 @@ public class DefaultTrackSelector extends MappingTrackSelector { int selectedTrackScore = 0; int selectedBitrate = Format.NO_VALUE; int selectedPixelCount = Format.NO_VALUE; - boolean selectedSatisfiesMaxConstraints; - boolean selectedSatisfiesMinConstraints; for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup trackGroup = groups.get(groupIndex); List selectedTrackIndices = getViewportFilteredTrackIndices(trackGroup, @@ -2060,50 +2066,68 @@ public class DefaultTrackSelector extends MappingTrackSelector { && (format.width == Format.NO_VALUE || format.width <= params.maxVideoWidth) && (format.height == Format.NO_VALUE || format.height <= params.maxVideoHeight) && (format.frameRate == Format.NO_VALUE - || format.frameRate <= params.maxVideoFrameRate) + || format.frameRate <= params.maxVideoFrameRate) && (format.bitrate == Format.NO_VALUE - || format.bitrate <= params.maxVideoBitrate); + || format.bitrate <= params.maxVideoBitrate); boolean satisfiesMinConstraints = selectedTrackIndices.contains(trackIndex) && (format.width == Format.NO_VALUE || format.width >= params.minVideoWidth) && (format.height == Format.NO_VALUE || format.height >= params.minVideoHeight) && (format.frameRate == Format.NO_VALUE - || format.frameRate >= params.minVideoFrameRate) + || format.frameRate >= params.minVideoFrameRate) && (format.bitrate == Format.NO_VALUE - || format.bitrate >= params.minVideoBitrate); + || format.bitrate >= params.minVideoBitrate); if (!satisfiesMaxConstraints && !params.exceedVideoConstraintsIfNecessary) { // Track should not be selected. continue; } int trackScore = 1; - if (satisfiesMaxConstraints) { - trackScore += 1; - } - if (satisfiesMinConstraints) { - trackScore += 1; - } boolean isWithinCapabilities = isSupported(trackFormatSupport[trackIndex], false); if (isWithinCapabilities) { trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; } + if (satisfiesMaxConstraints) { + trackScore += SATISFIES_MAX_CONSTRAINTS_BONUS; + } + if (satisfiesMinConstraints) { + trackScore += SATISFIES_MIN_CONSTRAINTS_BONUS; + } boolean selectTrack = trackScore > selectedTrackScore; + // Handling tie-breaking scenarios. if (trackScore == selectedTrackScore) { - // TODO handle tie breaker cases correctly. int bitrateComparison = compareFormatValues(format.bitrate, selectedBitrate); if (params.forceLowestBitrate && bitrateComparison != 0) { // Use bitrate as a tie breaker, preferring the lower bitrate. selectTrack = bitrateComparison < 0; } else { - // Use the pixel count as a tie breaker (or bitrate if pixel counts are tied). If - // we're within constraints prefer a higher pixel count (or bitrate), else prefer a - // lower count (or bitrate). If still tied then prefer the first track (i.e. the one - // that's already selected). + // Use the pixel count as a tie breaker (or bitrate if pixel counts are tied). int formatPixelCount = format.getPixelCount(); int comparisonResult = formatPixelCount != selectedPixelCount ? compareFormatValues(formatPixelCount, selectedPixelCount) : compareFormatValues(format.bitrate, selectedBitrate); - selectTrack = isWithinCapabilities && satisfiesMaxConstraints - ? comparisonResult > 0 : comparisonResult < 0; + // If it's not within the capabilities, always pick lower quality because it's more + // likely to play successfully. + if (!isWithinCapabilities) { + selectTrack = comparisonResult < 0; + } else { + if (satisfiesMinConstraints && satisfiesMaxConstraints) { + // Both constraints are satisfied, pick higher quality. + selectTrack = comparisonResult > 0; + } else if (!satisfiesMinConstraints && satisfiesMaxConstraints) { + // Min constraints are not satisfied but the max constraints are, pick higher + // quality, because that's what gets us closest to satisfying the violated min + // constraints. + selectTrack = comparisonResult > 0; + } else if (satisfiesMinConstraints) { + // Min constraints are satisfied but not the max constraints, pick lower quality + // because that's what gets us closest to satisfying the violated max constraints. + selectTrack = comparisonResult > 0; + } else { + // Neither min or max constraints are not satisfied, pick lower quality because + // it's more likely to play successfully. + selectTrack = comparisonResult < 0; + } + } } } if (selectTrack) { @@ -2112,8 +2136,6 @@ public class DefaultTrackSelector extends MappingTrackSelector { selectedTrackScore = trackScore; selectedBitrate = format.bitrate; selectedPixelCount = format.getPixelCount(); - selectedSatisfiesMaxConstraints = satisfiesMaxConstraints; - selectedSatisfiesMinConstraints = satisfiesMinConstraints; } } } From 1062edf52e3b31553d23693a41f1363af536040f Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Mon, 1 Jun 2020 09:50:40 +0100 Subject: [PATCH 0390/1052] Revert "Update TrackSelectionDialogBuilder to use androidx compat Dialog." This reverts commit b05e9944ea95c2b1a341610568e5cfbe8df6f333. --- RELEASENOTES.md | 2 -- library/ui/build.gradle | 1 - .../android/exoplayer2/ui/TrackSelectionDialogBuilder.java | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 370dd47775..2706e302cd 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -33,8 +33,6 @@ * Fix `DefaultTimeBar` to respect touch transformations ([#7303](https://github.com/google/ExoPlayer/issues/7303)). * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. - * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog - ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Text: * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles. diff --git a/library/ui/build.gradle b/library/ui/build.gradle index 8727ba416a..b6bf139963 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -40,7 +40,6 @@ dependencies { implementation project(modulePrefix + 'library-core') api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java index 5c91645a4c..f8a016bc8b 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java @@ -15,12 +15,12 @@ */ package com.google.android.exoplayer2.ui; +import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; From c13be44f68d5782f9a8173bd88de03d60ac146df Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 1 Jun 2020 10:46:01 +0100 Subject: [PATCH 0391/1052] Add one hour on-demand test video PiperOrigin-RevId: 314100932 --- demos/main/src/main/assets/media.exolist.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index b13241f691..db07652312 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -337,6 +337,10 @@ { "name": "Big Buck Bunny 480p video (MP4,AV1)", "uri": "https://storage.googleapis.com/downloads.webmproject.org/av1/exoplayer/bbb-av1-480p.mp4" + }, + { + "name": "One hour frame counter (MP4)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4" } ] }, From 18eccf9a819c7c3a0c840711a4b1ae0e8364e864 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 23 Apr 2020 19:59:10 +0100 Subject: [PATCH 0392/1052] Make sure not to create new playback sessions while still IDLE. The first session should only be created once we have the media items and/or called prepare. Otherwise the first session is created with an EventTime having an empty timeline making it less useful. issue:#7193 PiperOrigin-RevId: 308100555 --- .../analytics/PlaybackStatsListener.java | 41 +++++++++++-------- .../analytics/PlaybackStatsListenerTest.java | 39 +++++++++++++++++- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index 43d2496842..d45e96166f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -243,7 +243,7 @@ public final class PlaybackStatsListener EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { this.playWhenReady = playWhenReady; this.playbackState = playbackState; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -256,7 +256,7 @@ public final class PlaybackStatsListener public void onPlaybackSuppressionReasonChanged( EventTime eventTime, int playbackSuppressionReason) { isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -268,7 +268,7 @@ public final class PlaybackStatsListener @Override public void onTimelineChanged(EventTime eventTime, int reason) { sessionManager.handleTimelineUpdate(eventTime); - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); @@ -279,7 +279,7 @@ public final class PlaybackStatsListener @Override public void onPositionDiscontinuity(EventTime eventTime, int reason) { sessionManager.handlePositionDiscontinuity(eventTime, reason); - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); @@ -289,7 +289,7 @@ public final class PlaybackStatsListener @Override public void onSeekStarted(EventTime eventTime) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onSeekStarted(eventTime); @@ -299,7 +299,7 @@ public final class PlaybackStatsListener @Override public void onSeekProcessed(EventTime eventTime) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onSeekProcessed(eventTime); @@ -309,7 +309,7 @@ public final class PlaybackStatsListener @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onFatalError(eventTime, error); @@ -321,7 +321,7 @@ public final class PlaybackStatsListener public void onPlaybackParametersChanged( EventTime eventTime, PlaybackParameters playbackParameters) { playbackSpeed = playbackParameters.speed; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); } @@ -330,7 +330,7 @@ public final class PlaybackStatsListener @Override public void onTracksChanged( EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections); @@ -341,7 +341,7 @@ public final class PlaybackStatsListener @Override public void onLoadStarted( EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onLoadStarted(eventTime); @@ -351,7 +351,7 @@ public final class PlaybackStatsListener @Override public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData); @@ -366,7 +366,7 @@ public final class PlaybackStatsListener int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height); @@ -377,7 +377,7 @@ public final class PlaybackStatsListener @Override public void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded); @@ -388,7 +388,7 @@ public final class PlaybackStatsListener @Override public void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onAudioUnderrun(); @@ -398,7 +398,7 @@ public final class PlaybackStatsListener @Override public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames); @@ -413,7 +413,7 @@ public final class PlaybackStatsListener MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); @@ -423,7 +423,7 @@ public final class PlaybackStatsListener @Override public void onDrmSessionManagerError(EventTime eventTime, Exception error) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); @@ -431,6 +431,13 @@ public final class PlaybackStatsListener } } + private void maybeAddSession(EventTime eventTime) { + boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; + if (!isCompletelyIdle) { + sessionManager.updateSessions(eventTime); + } + } + /** Tracker for playback stats of a single playback. */ private static final class PlaybackStatsTracker { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java index 10122d36ec..ef3e4d1434 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java @@ -21,6 +21,8 @@ import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.testutil.FakeTimeline; import org.junit.Test; import org.junit.runner.RunWith; @@ -28,7 +30,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class PlaybackStatsListenerTest { - private static final AnalyticsListener.EventTime TEST_EVENT_TIME = + private static final AnalyticsListener.EventTime EMPTY_TIMELINE_EVENT_TIME = new AnalyticsListener.EventTime( /* realtimeMs= */ 500, Timeline.EMPTY, @@ -37,6 +39,41 @@ public final class PlaybackStatsListenerTest { /* eventPlaybackPositionMs= */ 0, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); + private static final Timeline TEST_TIMELINE = new FakeTimeline(/* windowCount= */ 1); + private static final AnalyticsListener.EventTime TEST_EVENT_TIME = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 700, + TEST_TIMELINE, + /* windowIndex= */ 0, + new MediaSource.MediaPeriodId( + TEST_TIMELINE.getPeriod( + /* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true) + .uid, + /* windowSequenceNumber= */ 42), + /* eventPlaybackPositionMs= */ 123, + /* currentPlaybackPositionMs= */ 123, + /* totalBufferedDurationMs= */ 456); + + @Test + public void stateChangeEvent_toNonIdle_createsInitialPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onPlayerStateChanged( + EMPTY_TIMELINE_EVENT_TIME, /* playWhenReady= */ false, Player.STATE_BUFFERING); + + assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); + } + + @Test + public void timelineChangeEvent_toNonEmpty_createsInitialPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onTimelineChanged(TEST_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + + assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); + } @Test public void playback_withKeepHistory_updatesStats() { From f4cc1d6250adb1cb110b43221c87a39624ed8b37 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 24 Apr 2020 16:02:35 +0100 Subject: [PATCH 0393/1052] Make sure finishAllSessions() can be called without removing listener Currently, this method is only supposed to be called before removing the listener from the player or when releasing the player. If called at other times, it will throw an exception later when a playback session is ended automatically. issue:#7193 PiperOrigin-RevId: 308254993 --- .../DefaultPlaybackSessionManager.java | 15 ++++ .../analytics/PlaybackSessionManager.java | 8 ++ .../analytics/PlaybackStatsListener.java | 5 +- .../DefaultPlaybackSessionManagerTest.java | 26 +++++++ .../analytics/PlaybackStatsListenerTest.java | 77 ++++++++++++++++++- 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java index 44f8c10afe..1fbcf80dc1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -168,6 +168,21 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag updateActiveSession(eventTime, activeSessionDescriptor); } + @Override + public void finishAllSessions(EventTime eventTime) { + currentMediaPeriodId = null; + activeSessionId = null; + Iterator iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + iterator.remove(); + if (session.isCreated && listener != null) { + listener.onSessionFinished( + eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); + } + } + } + private SessionDescriptor getOrAddSession( int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { // There should only be one matching session if mediaPeriodId is non-null. If mediaPeriodId is diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java index 53d63e23fc..7045779125 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -117,4 +117,12 @@ public interface PlaybackSessionManager { * @param reason The {@link DiscontinuityReason}. */ void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); + + /** + * Finishes all existing sessions and calls their respective {@link + * Listener#onSessionFinished(EventTime, String, boolean)} callback. + * + * @param eventTime The event time at which sessions are finished. + */ + void finishAllSessions(EventTime eventTime); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index d45e96166f..46c0a05342 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -148,7 +148,6 @@ public final class PlaybackStatsListener // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with // an actual EventTime. Should also simplify other cases where the listener needs to be released // separately from the player. - HashMap trackerCopy = new HashMap<>(playbackStatsTrackers); EventTime dummyEventTime = new EventTime( SystemClock.elapsedRealtime(), @@ -158,9 +157,7 @@ public final class PlaybackStatsListener /* eventPlaybackPositionMs= */ 0, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); - for (String session : trackerCopy.keySet()) { - onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false); - } + sessionManager.finishAllSessions(dummyEventTime); } // PlaybackSessionManager.Listener implementation. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java index f0b18b4a20..6828a04a37 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -21,6 +21,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -1044,6 +1045,31 @@ public final class DefaultPlaybackSessionManagerTest { verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); } + @Test + public void finishAllSessions_callsOnSessionFinishedForAllCreatedSessions() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 4); + EventTime eventTimeWindow0 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTimeWindow2 = + createEventTime(timeline, /* windowIndex= */ 2, /* mediaPeriodId= */ null); + // Actually create sessions for window 0 and 2. + sessionManager.updateSessions(eventTimeWindow0); + sessionManager.updateSessions(eventTimeWindow2); + // Query information about session for window 1, but don't create it. + sessionManager.getSessionForMediaPeriodId( + timeline, + new MediaPeriodId( + timeline.getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true).uid, + /* windowSequenceNumber= */ 123)); + verify(mockListener, times(2)).onSessionCreated(any(), anyString()); + + EventTime finishEventTime = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + sessionManager.finishAllSessions(finishEventTime); + + verify(mockListener, times(2)).onSessionFinished(eq(finishEventTime), anyString(), eq(false)); + } + private static EventTime createEventTime( Timeline timeline, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { return new EventTime( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java index ef3e4d1434..41db4dc570 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java @@ -16,7 +16,14 @@ package com.google.android.exoplayer2.analytics; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Player; @@ -42,7 +49,7 @@ public final class PlaybackStatsListenerTest { private static final Timeline TEST_TIMELINE = new FakeTimeline(/* windowCount= */ 1); private static final AnalyticsListener.EventTime TEST_EVENT_TIME = new AnalyticsListener.EventTime( - /* realtimeMs= */ 700, + /* realtimeMs= */ 500, TEST_TIMELINE, /* windowIndex= */ 0, new MediaSource.MediaPeriodId( @@ -108,4 +115,72 @@ public final class PlaybackStatsListenerTest { assertThat(playbackStats).isNotNull(); assertThat(playbackStats.endedCount).isEqualTo(1); } + + @Test + public void finishedSession_callsCallback() { + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + + // Create session with an event and finish it by simulating removal from playlist. + playbackStatsListener.onPlayerStateChanged( + TEST_EVENT_TIME, /* playWhenReady= */ false, Player.STATE_BUFFERING); + verify(callback, never()).onPlaybackStatsReady(any(), any()); + playbackStatsListener.onTimelineChanged( + EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + + verify(callback).onPlaybackStatsReady(eq(TEST_EVENT_TIME), any()); + } + + @Test + public void finishAllSessions_callsAllPendingCallbacks() { + AnalyticsListener.EventTime eventTimeWindow0 = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 0, + Timeline.EMPTY, + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + AnalyticsListener.EventTime eventTimeWindow1 = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 0, + Timeline.EMPTY, + /* windowIndex= */ 1, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + playbackStatsListener.onPlayerStateChanged( + eventTimeWindow0, /* playWhenReady= */ false, Player.STATE_BUFFERING); + playbackStatsListener.onPlayerStateChanged( + eventTimeWindow1, /* playWhenReady= */ false, Player.STATE_BUFFERING); + + playbackStatsListener.finishAllSessions(); + + verify(callback, times(2)).onPlaybackStatsReady(any(), any()); + verify(callback).onPlaybackStatsReady(eq(eventTimeWindow0), any()); + verify(callback).onPlaybackStatsReady(eq(eventTimeWindow1), any()); + } + + @Test + public void finishAllSessions_doesNotCallCallbackAgainWhenSessionWouldBeAutomaticallyFinished() { + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + playbackStatsListener.onPlayerStateChanged( + TEST_EVENT_TIME, /* playWhenReady= */ false, Player.STATE_BUFFERING); + SystemClock.setCurrentTimeMillis(TEST_EVENT_TIME.realtimeMs + 100); + + playbackStatsListener.finishAllSessions(); + // Simulate removing the playback item to ensure the session would finish if it hadn't already. + playbackStatsListener.onTimelineChanged( + EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + + verify(callback).onPlaybackStatsReady(any(), any()); + } } From 145754618d3a5f3aa88dd7b7fba47803b19c61d5 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 13 Jan 2020 14:52:14 +0000 Subject: [PATCH 0394/1052] Simplify keeping track of current id in DefaultPlaybackSessionManager We currently have a currentMediaPeriodId and an activeSessionId that are more or less tracking the same thing unless the current media period isn't "active" yet. Simplify this logic by using a single currentSessionId field and the respective isActive flag of this session. Also move all session creation and activation code in the same method to make it easier to reason about the code. This change also fixes a subtle bug where events after a seek to a new window are not ignored as they should. PiperOrigin-RevId: 289432181 --- .../DefaultPlaybackSessionManager.java | 95 ++++++++++--------- .../DefaultPlaybackSessionManagerTest.java | 14 +++ 2 files changed, 62 insertions(+), 47 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java index 1fbcf80dc1..04f3ba154a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -29,7 +29,6 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Random; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Default {@link PlaybackSessionManager} which instantiates a new session for each window in the @@ -48,8 +47,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag private @MonotonicNonNull Listener listener; private Timeline currentTimeline; - @Nullable private MediaPeriodId currentMediaPeriodId; - @Nullable private String activeSessionId; + @Nullable private String currentSessionId; /** Creates session manager. */ public DefaultPlaybackSessionManager() { @@ -83,22 +81,34 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag @Override public synchronized void updateSessions(EventTime eventTime) { - boolean isObviouslyFinished = - eventTime.mediaPeriodId != null - && currentMediaPeriodId != null - && eventTime.mediaPeriodId.windowSequenceNumber - < currentMediaPeriodId.windowSequenceNumber; - if (!isObviouslyFinished) { - SessionDescriptor descriptor = - getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); - if (!descriptor.isCreated) { - descriptor.isCreated = true; - Assertions.checkNotNull(listener).onSessionCreated(eventTime, descriptor.sessionId); - if (activeSessionId == null) { - updateActiveSession(eventTime, descriptor); - } + Assertions.checkNotNull(listener); + @Nullable SessionDescriptor currentSession = sessions.get(currentSessionId); + if (eventTime.mediaPeriodId != null && currentSession != null) { + // If we receive an event associated with a media period, then it needs to be either part of + // the current window if it's the first created media period, or a window that will be played + // in the future. Otherwise, we know that it belongs to a session that was already finished + // and we can ignore the event. + boolean isAlreadyFinished = + currentSession.windowSequenceNumber == C.INDEX_UNSET + ? currentSession.windowIndex != eventTime.windowIndex + : eventTime.mediaPeriodId.windowSequenceNumber < currentSession.windowSequenceNumber; + if (isAlreadyFinished) { + return; } } + SessionDescriptor eventSession = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (currentSessionId == null) { + currentSessionId = eventSession.sessionId; + } + if (!eventSession.isCreated) { + eventSession.isCreated = true; + listener.onSessionCreated(eventTime, eventSession.sessionId); + } + if (eventSession.sessionId.equals(currentSessionId) && !eventSession.isActive) { + eventSession.isActive = true; + listener.onSessionActive(eventTime, eventSession.sessionId); + } } @Override @@ -112,8 +122,8 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag if (!session.tryResolvingToNewTimeline(previousTimeline, currentTimeline)) { iterator.remove(); if (session.isCreated) { - if (session.sessionId.equals(activeSessionId)) { - activeSessionId = null; + if (session.sessionId.equals(currentSessionId)) { + currentSessionId = null; } listener.onSessionFinished( eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); @@ -136,42 +146,46 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag if (session.isFinishedAtEventTime(eventTime)) { iterator.remove(); if (session.isCreated) { - boolean isRemovingActiveSession = session.sessionId.equals(activeSessionId); - boolean isAutomaticTransition = hasAutomaticTransition && isRemovingActiveSession; - if (isRemovingActiveSession) { - activeSessionId = null; + boolean isRemovingCurrentSession = session.sessionId.equals(currentSessionId); + boolean isAutomaticTransition = + hasAutomaticTransition && isRemovingCurrentSession && session.isActive; + if (isRemovingCurrentSession) { + currentSessionId = null; } listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition); } } } - SessionDescriptor activeSessionDescriptor = + @Nullable SessionDescriptor previousSessionDescriptor = sessions.get(currentSessionId); + SessionDescriptor currentSessionDescriptor = getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + currentSessionId = currentSessionDescriptor.sessionId; if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd() - && (currentMediaPeriodId == null - || currentMediaPeriodId.windowSequenceNumber + && (previousSessionDescriptor == null + || previousSessionDescriptor.windowSequenceNumber != eventTime.mediaPeriodId.windowSequenceNumber - || currentMediaPeriodId.adGroupIndex != eventTime.mediaPeriodId.adGroupIndex - || currentMediaPeriodId.adIndexInAdGroup != eventTime.mediaPeriodId.adIndexInAdGroup)) { + || previousSessionDescriptor.adMediaPeriodId == null + || previousSessionDescriptor.adMediaPeriodId.adGroupIndex + != eventTime.mediaPeriodId.adGroupIndex + || previousSessionDescriptor.adMediaPeriodId.adIndexInAdGroup + != eventTime.mediaPeriodId.adIndexInAdGroup)) { // New ad playback started. Find corresponding content session and notify ad playback started. MediaPeriodId contentMediaPeriodId = new MediaPeriodId( eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); SessionDescriptor contentSession = getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); - if (contentSession.isCreated && activeSessionDescriptor.isCreated) { + if (contentSession.isCreated && currentSessionDescriptor.isCreated) { listener.onAdPlaybackStarted( - eventTime, contentSession.sessionId, activeSessionDescriptor.sessionId); + eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); } } - updateActiveSession(eventTime, activeSessionDescriptor); } @Override public void finishAllSessions(EventTime eventTime) { - currentMediaPeriodId = null; - activeSessionId = null; + currentSessionId = null; Iterator iterator = sessions.values().iterator(); while (iterator.hasNext()) { SessionDescriptor session = iterator.next(); @@ -214,18 +228,6 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag return bestMatch; } - @RequiresNonNull("listener") - private void updateActiveSession(EventTime eventTime, SessionDescriptor sessionDescriptor) { - currentMediaPeriodId = eventTime.mediaPeriodId; - if (sessionDescriptor.isCreated) { - activeSessionId = sessionDescriptor.sessionId; - if (!sessionDescriptor.isActive) { - sessionDescriptor.isActive = true; - listener.onSessionActive(eventTime, sessionDescriptor.sessionId); - } - } - } - private static String generateSessionId() { byte[] randomBytes = new byte[SESSION_ID_LENGTH]; RANDOM.nextBytes(randomBytes); @@ -299,8 +301,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { if (windowSequenceNumber == C.INDEX_UNSET && eventWindowIndex == windowIndex - && eventMediaPeriodId != null - && !eventMediaPeriodId.isAd()) { + && eventMediaPeriodId != null) { // Set window sequence number for this session as soon as we have one. windowSequenceNumber = eventMediaPeriodId.windowSequenceNumber; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java index 6828a04a37..1de3a8d1e5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -502,6 +502,7 @@ public final class DefaultPlaybackSessionManagerTest { createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); sessionManager.handleTimelineUpdate(newTimelineEventTime); + sessionManager.updateSessions(newTimelineEventTime); ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); @@ -658,6 +659,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eq(eventTime1), anyString()); verify(mockListener).onSessionActive(eq(eventTime1), anyString()); @@ -689,6 +691,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -723,6 +726,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -749,6 +753,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.updateSessions(eventTime2); sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(eventTime2); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -791,6 +796,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); sessionManager.handlePositionDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(eventTime3); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -852,6 +858,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( contentEventTime, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(contentEventTime); verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); verify(mockListener).onSessionActive(adEventTime1, adSessionId1); @@ -859,6 +866,8 @@ public final class DefaultPlaybackSessionManagerTest { verify(mockListener) .onSessionFinished( contentEventTime, adSessionId1, /* automaticTransitionToNextPlayback= */ true); + verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); + verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); verifyNoMoreInteractions(mockListener); } @@ -909,6 +918,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(adEventTime1); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -965,7 +975,9 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(adEventTime1); sessionManager.handlePositionDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(adEventTime2); verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); @@ -1035,8 +1047,10 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.updateSessions(adEventTime1); sessionManager.handlePositionDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(adEventTime1); sessionManager.handlePositionDiscontinuity( contentEventTime2, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(contentEventTime2); String adSessionId2 = sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); From 9f87c2eaefa2262dadcfb59d64ada484fe491f9e Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Mon, 1 Jun 2020 19:12:39 +0100 Subject: [PATCH 0395/1052] Finalize release notes --- RELEASENOTES.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2706e302cd..2422591442 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -38,18 +38,6 @@ subtitles. * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct color. -* Cronet extension: Default to using the Cronet implementation in Google Play - Services rather than Cronet Embedded. This allows Cronet to be used with a - negligible increase in application size, compared to approximately 8MB when - embedding the library. -* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. -* MediaSession extension: - * Only set the playback state to `BUFFERING` if `playWhenReady` is true - ([#7206](https://github.com/google/ExoPlayer/issues/7206)). - * Add missing `@Nullable` annotations to `MediaSessionConnector` - ([#7234](https://github.com/google/ExoPlayer/issues/7234)). -* AV1 extension: Add a heuristic to determine the default number of threads - used for AV1 playback using the extension. * IMA extension: * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs @@ -70,6 +58,18 @@ ([#5444](https://github.com/google/ExoPlayer/issues/5444), [#5966](https://github.com/google/ExoPlayer/issues/5966), [#7002](https://github.com/google/ExoPlayer/issues/7002)). +* Cronet extension: Default to using the Cronet implementation in Google Play + Services rather than Cronet Embedded. This allows Cronet to be used with a + negligible increase in application size, compared to approximately 8MB when + embedding the library. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* MediaSession extension: + * Only set the playback state to `BUFFERING` if `playWhenReady` is true + ([#7206](https://github.com/google/ExoPlayer/issues/7206)). + * Add missing `@Nullable` annotations to `MediaSessionConnector` + ([#7234](https://github.com/google/ExoPlayer/issues/7234)). +* AV1 extension: Add a heuristic to determine the default number of threads + used for AV1 playback using the extension. ### 2.11.4 (2020-04-08) ### From 5e2b89b562e6970c1f8ffca8efa9fb20d23dbac6 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Mon, 1 Jun 2020 19:15:15 +0100 Subject: [PATCH 0396/1052] Add release date --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2422591442..e953578ee2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,6 +1,6 @@ # Release notes # -### 2.11.5 (not yet released) ### +### 2.11.5 (2020-06-03) ### * Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` From 2f3c7cb85f9d0166de5724dd5134616351c1747a Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 1 Jun 2020 20:01:58 +0100 Subject: [PATCH 0397/1052] Revert pushing download thread interruption into the Downloader implementations Issue: #5978 PiperOrigin-RevId: 314175257 --- .../android/exoplayer2/offline/DownloadManager.java | 1 + .../android/exoplayer2/offline/Downloader.java | 5 ++++- .../exoplayer2/offline/ProgressiveDownloader.java | 12 ------------ .../exoplayer2/offline/SegmentDownloader.java | 10 ---------- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 32931d9f32..a45c95470e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -1284,6 +1284,7 @@ public final class DownloadManager { if (!isCanceled) { isCanceled = true; downloader.cancel(); + interrupt(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java index 98079bf200..56f8c0ce8d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java @@ -50,7 +50,10 @@ public interface Downloader { */ void download(@Nullable ProgressListener progressListener) throws IOException; - /** Cancels the download operation and prevents future download operations from running. */ + /** + * Cancels the download operation and prevents future download operations from running. The caller + * should also interrupt the downloading thread immediately after calling this method. + */ void cancel(); /** Removes the content. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 42e2c7e84d..dd251dad26 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -22,7 +22,6 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheWriter; -import com.google.android.exoplayer2.upstream.cache.CacheWriter.ProgressListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; @@ -37,8 +36,6 @@ public final class ProgressiveDownloader implements Downloader { private final CacheDataSource dataSource; private final AtomicBoolean isCanceled; - @Nullable private volatile Thread downloadThread; - /** @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory)} instead. */ @SuppressWarnings("deprecation") @Deprecated @@ -100,11 +97,6 @@ public final class ProgressiveDownloader implements Downloader { @Override public void download(@Nullable ProgressListener progressListener) throws IOException { - downloadThread = Thread.currentThread(); - if (isCanceled.get()) { - return; - } - CacheWriter cacheWriter = new CacheWriter( dataSource, @@ -143,10 +135,6 @@ public final class ProgressiveDownloader implements Downloader { @Override public void cancel() { isCanceled.set(true); - @Nullable Thread downloadThread = this.downloadThread; - if (downloadThread != null) { - downloadThread.interrupt(); - } } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 28ed994168..f5abd3228e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -80,8 +80,6 @@ public abstract class SegmentDownloader> impleme private final Executor executor; private final AtomicBoolean isCanceled; - @Nullable private volatile Thread downloadThread; - /** * @param mediaItem The {@link MediaItem} to be downloaded. * @param manifestParser A parser for the manifest. @@ -107,10 +105,6 @@ public abstract class SegmentDownloader> impleme @Override public final void download(@Nullable ProgressListener progressListener) throws IOException { - downloadThread = Thread.currentThread(); - if (isCanceled.get()) { - return; - } @Nullable PriorityTaskManager priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); @@ -212,10 +206,6 @@ public abstract class SegmentDownloader> impleme @Override public void cancel() { isCanceled.set(true); - @Nullable Thread downloadThread = this.downloadThread; - if (downloadThread != null) { - downloadThread.interrupt(); - } } @Override From 75e54a452a467ccc575fed33c885f7bdaf82be29 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 1 Jun 2020 21:36:52 +0100 Subject: [PATCH 0398/1052] Use identical media item for constructor if possible If no deprecated methods on the factory is called, the media item instance that is passed to the createMediaSource method must be passed down to the constructor of the media source. PiperOrigin-RevId: 314193865 --- .../source/ProgressiveMediaSource.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index 259f293a94..6c1d26cb07 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -180,15 +180,18 @@ public final class ProgressiveMediaSource extends BaseMediaSource @Override public ProgressiveMediaSource createMediaSource(MediaItem mediaItem) { checkNotNull(mediaItem.playbackProperties); - MediaItem.Builder builder = mediaItem.buildUpon(); - if (mediaItem.playbackProperties.tag == null) { - builder.setTag(tag); - } - if (mediaItem.playbackProperties.customCacheKey == null) { - builder.setCustomCacheKey(customCacheKey); + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsCustomCacheKey = + mediaItem.playbackProperties.customCacheKey == null && customCacheKey != null; + if (needsTag && needsCustomCacheKey) { + mediaItem = mediaItem.buildUpon().setTag(tag).setCustomCacheKey(customCacheKey).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsCustomCacheKey) { + mediaItem = mediaItem.buildUpon().setCustomCacheKey(customCacheKey).build(); } return new ProgressiveMediaSource( - builder.build(), + mediaItem, dataSourceFactory, extractorsFactory, drmSessionManager, From a5067e6314931a40b61868e9a54ae503aa29525d Mon Sep 17 00:00:00 2001 From: krocard Date: Tue, 2 Jun 2020 09:43:12 +0100 Subject: [PATCH 0399/1052] Implement offload AudioTrack in DefaultAudioSink. #exo-offload PiperOrigin-RevId: 314288300 --- .../exoplayer2/audio/DefaultAudioSink.java | 246 ++++++++++++------ .../audio/DefaultAudioSinkTest.java | 3 +- 2 files changed, 168 insertions(+), 81 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 27bbeb91f6..bc3c321cac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -35,15 +35,16 @@ import java.nio.ByteOrder; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Plays audio data. The implementation delegates to an {@link AudioTrack} and handles playback * position smoothing, non-blocking writes and reconfiguration. - *

      - * If tunneling mode is enabled, care must be taken that audio processors do not output buffers with - * a different duration than their input, and buffer processors must produce output corresponding to - * their last input immediately after that input is queued. This means that, for example, speed - * adjustment is not possible while using tunneling. + * + *

      If tunneling mode is enabled, care must be taken that audio processors do not output buffers + * with a different duration than their input, and buffer processors must produce output + * corresponding to their last input immediately after that input is queued. This means that, for + * example, speed adjustment is not possible while using tunneling. */ public final class DefaultAudioSink implements AudioSink { @@ -204,6 +205,9 @@ public final class DefaultAudioSink implements AudioSink { private static final long MAX_BUFFER_DURATION_US = 750_000; /** The length for passthrough {@link AudioTrack} buffers, in microseconds. */ private static final long PASSTHROUGH_BUFFER_DURATION_US = 250_000; + /** The length for offload {@link AudioTrack} buffers, in microseconds. */ + private static final long OFFLOAD_BUFFER_DURATION_US = 50_000_000; + /** * A multiplication factor to apply to the minimum buffer size requested by the underlying * {@link AudioTrack}. @@ -269,14 +273,15 @@ public final class DefaultAudioSink implements AudioSink { private final ConditionVariable releasingConditionVariable; private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; + private final boolean enableOffload; @Nullable private Listener listener; /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ @Nullable private AudioTrack keepSessionIdAudioTrack; @Nullable private Configuration pendingConfiguration; - private Configuration configuration; - private AudioTrack audioTrack; + @MonotonicNonNull private Configuration configuration; + @Nullable private AudioTrack audioTrack; private AudioAttributes audioAttributes; @Nullable private MediaPositionParameters afterDrainParameters; @@ -340,7 +345,11 @@ public final class DefaultAudioSink implements AudioSink { @Nullable AudioCapabilities audioCapabilities, AudioProcessor[] audioProcessors, boolean enableFloatOutput) { - this(audioCapabilities, new DefaultAudioProcessorChain(audioProcessors), enableFloatOutput); + this( + audioCapabilities, + new DefaultAudioProcessorChain(audioProcessors), + enableFloatOutput, + /* enableOffload= */ false); } /** @@ -355,14 +364,19 @@ public final class DefaultAudioSink implements AudioSink { * output will be used if the input is 32-bit float, and also if the input is high resolution * (24-bit or 32-bit) integer PCM. Audio processing (for example, speed adjustment) will not * be available when float output is in use. + * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played + * with offload and encoded audio passthrough, it will be played in offload. Audio offload is + * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, AudioProcessorChain audioProcessorChain, - boolean enableFloatOutput) { + boolean enableFloatOutput, + boolean enableOffload) { this.audioCapabilities = audioCapabilities; this.audioProcessorChain = Assertions.checkNotNull(audioProcessorChain); this.enableFloatOutput = enableFloatOutput; + this.enableOffload = Util.SDK_INT >= 29 && enableOffload; releasingConditionVariable = new ConditionVariable(true); audioTrackPositionTracker = new AudioTrackPositionTracker(new PositionTrackerListener()); channelMappingAudioProcessor = new ChannelMappingAudioProcessor(); @@ -410,9 +424,12 @@ public final class DefaultAudioSink implements AudioSink { // sink to 16-bit PCM. We assume that the audio framework will downsample any number of // channels to the output device's required number of channels. return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; - } else { - return isPassthroughPlaybackSupported(encoding, channelCount); } + if (enableOffload + && isOffloadedPlaybackSupported(channelCount, sampleRateHz, encoding, audioAttributes)) { + return true; + } + return isPassthroughPlaybackSupported(encoding, channelCount); } @Override @@ -485,6 +502,11 @@ public final class DefaultAudioSink implements AudioSink { int outputPcmFrameSize = isInputPcm ? Util.getPcmFrameSize(encoding, channelCount) : C.LENGTH_UNSET; boolean canApplyPlaybackParameters = processingEnabled && !useFloatOutput; + boolean useOffload = + enableOffload + && !isInputPcm + && isOffloadedPlaybackSupported(channelCount, sampleRate, encoding, audioAttributes); + Configuration pendingConfiguration = new Configuration( isInputPcm, @@ -497,7 +519,8 @@ public final class DefaultAudioSink implements AudioSink { specifiedBufferSize, processingEnabled, canApplyPlaybackParameters, - availableAudioProcessors); + availableAudioProcessors, + useOffload); if (isInitialized()) { this.pendingConfiguration = pendingConfiguration; } else { @@ -786,8 +809,9 @@ public final class DefaultAudioSink implements AudioSink { } } else if (tunneling) { Assertions.checkState(avSyncPresentationTimeUs != C.TIME_UNSET); - bytesWritten = writeNonBlockingWithAvSyncV21(audioTrack, buffer, bytesRemaining, - avSyncPresentationTimeUs); + bytesWritten = + writeNonBlockingWithAvSyncV21( + audioTrack, buffer, bytesRemaining, avSyncPresentationTimeUs); } else { bytesWritten = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); } @@ -1191,6 +1215,20 @@ public final class DefaultAudioSink implements AudioSink { || channelCount <= audioCapabilities.getMaxChannelCount()); } + private static boolean isOffloadedPlaybackSupported( + int channelCount, + int sampleRateHz, + @C.Encoding int encoding, + AudioAttributes audioAttributes) { + if (Util.SDK_INT < 29) { + return false; + } + int channelMask = getChannelConfig(channelCount, /* isInputPcm= */ false); + AudioFormat audioFormat = getAudioFormat(sampleRateHz, channelMask, encoding); + return AudioManager.isOffloadedPlaybackSupported( + audioFormat, audioAttributes.getAudioAttributesV21()); + } + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; @@ -1386,6 +1424,15 @@ public final class DefaultAudioSink implements AudioSink { } } + @RequiresApi(21) + private static AudioFormat getAudioFormat(int sampleRate, int channelConfig, int encoding) { + return new AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .setEncoding(encoding) + .build(); + } + private final class PositionTrackerListener implements AudioTrackPositionTracker.Listener { @Override @@ -1466,6 +1513,7 @@ public final class DefaultAudioSink implements AudioSink { public final boolean processingEnabled; public final boolean canApplyPlaybackParameters; public final AudioProcessor[] availableAudioProcessors; + public final boolean useOffload; public Configuration( boolean isInputPcm, @@ -1478,7 +1526,8 @@ public final class DefaultAudioSink implements AudioSink { int specifiedBufferSize, boolean processingEnabled, boolean canApplyPlaybackParameters, - AudioProcessor[] availableAudioProcessors) { + AudioProcessor[] availableAudioProcessors, + boolean useOffload) { this.isInputPcm = isInputPcm; this.inputPcmFrameSize = inputPcmFrameSize; this.inputSampleRate = inputSampleRate; @@ -1486,16 +1535,22 @@ public final class DefaultAudioSink implements AudioSink { this.outputSampleRate = outputSampleRate; this.outputChannelConfig = outputChannelConfig; this.outputEncoding = outputEncoding; - this.bufferSize = specifiedBufferSize != 0 ? specifiedBufferSize : getDefaultBufferSize(); this.processingEnabled = processingEnabled; this.canApplyPlaybackParameters = canApplyPlaybackParameters; this.availableAudioProcessors = availableAudioProcessors; + this.useOffload = useOffload; + + // Call computeBufferSize() last as it depends on the other configuration values. + this.bufferSize = computeBufferSize(specifiedBufferSize); } + /** Returns if the configurations are sufficiently compatible to reuse the audio track. */ public boolean canReuseAudioTrack(Configuration audioTrackConfiguration) { return audioTrackConfiguration.outputEncoding == outputEncoding && audioTrackConfiguration.outputSampleRate == outputSampleRate - && audioTrackConfiguration.outputChannelConfig == outputChannelConfig; + && audioTrackConfiguration.outputChannelConfig == outputChannelConfig + && audioTrackConfiguration.outputPcmFrameSize == outputPcmFrameSize + && audioTrackConfiguration.useOffload == useOffload; } public long inputFramesToDurationUs(long frameCount) { @@ -1514,31 +1569,12 @@ public final class DefaultAudioSink implements AudioSink { boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) throws InitializationException { AudioTrack audioTrack; - if (Util.SDK_INT >= 21) { + if (Util.SDK_INT >= 29) { + audioTrack = createAudioTrackV29(tunneling, audioAttributes, audioSessionId); + } else if (Util.SDK_INT >= 21) { audioTrack = createAudioTrackV21(tunneling, audioAttributes, audioSessionId); } else { - int streamType = Util.getStreamTypeForAudioUsage(audioAttributes.usage); - if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { - audioTrack = - new AudioTrack( - streamType, - outputSampleRate, - outputChannelConfig, - outputEncoding, - bufferSize, - MODE_STREAM); - } else { - // Re-attach to the same audio session. - audioTrack = - new AudioTrack( - streamType, - outputSampleRate, - outputChannelConfig, - outputEncoding, - bufferSize, - MODE_STREAM, - audioSessionId); - } + audioTrack = createAudioTrack(audioAttributes, audioSessionId); } int state = audioTrack.getState(); @@ -1554,56 +1590,106 @@ public final class DefaultAudioSink implements AudioSink { return audioTrack; } + @RequiresApi(29) + private AudioTrack createAudioTrackV29( + boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) { + AudioFormat audioFormat = + getAudioFormat(outputSampleRate, outputChannelConfig, outputEncoding); + android.media.AudioAttributes audioTrackAttributes = + getAudioTrackAttributesV21(audioAttributes, tunneling); + return new AudioTrack.Builder() + .setAudioAttributes(audioTrackAttributes) + .setAudioFormat(audioFormat) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(bufferSize) + .setSessionId(audioSessionId) + .setOffloadedPlayback(useOffload) + .build(); + } + @RequiresApi(21) private AudioTrack createAudioTrackV21( boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) { - android.media.AudioAttributes attributes; - if (tunneling) { - attributes = - new android.media.AudioAttributes.Builder() - .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) - .setFlags(android.media.AudioAttributes.FLAG_HW_AV_SYNC) - .setUsage(android.media.AudioAttributes.USAGE_MEDIA) - .build(); - } else { - attributes = audioAttributes.getAudioAttributesV21(); - } - AudioFormat format = - new AudioFormat.Builder() - .setChannelMask(outputChannelConfig) - .setEncoding(outputEncoding) - .setSampleRate(outputSampleRate) - .build(); return new AudioTrack( - attributes, - format, + getAudioTrackAttributesV21(audioAttributes, tunneling), + getAudioFormat(outputSampleRate, outputChannelConfig, outputEncoding), bufferSize, MODE_STREAM, - audioSessionId != C.AUDIO_SESSION_ID_UNSET - ? audioSessionId - : AudioManager.AUDIO_SESSION_ID_GENERATE); + audioSessionId); } - private int getDefaultBufferSize() { - if (isInputPcm) { - int minBufferSize = - AudioTrack.getMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding); - Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); - int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; - int minAppBufferSize = - (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * outputPcmFrameSize; - int maxAppBufferSize = - (int) - Math.max( - minBufferSize, durationUsToFrames(MAX_BUFFER_DURATION_US) * outputPcmFrameSize); - return Util.constrainValue(multipliedBufferSize, minAppBufferSize, maxAppBufferSize); + private AudioTrack createAudioTrack(AudioAttributes audioAttributes, int audioSessionId) { + int streamType = Util.getStreamTypeForAudioUsage(audioAttributes.usage); + if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { + return new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + MODE_STREAM); } else { - int rate = getMaximumEncodedRateBytesPerSecond(outputEncoding); - if (outputEncoding == C.ENCODING_AC3) { - rate *= AC3_BUFFER_MULTIPLICATION_FACTOR; - } - return (int) (PASSTHROUGH_BUFFER_DURATION_US * rate / C.MICROS_PER_SECOND); + // Re-attach to the same audio session. + return new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + MODE_STREAM, + audioSessionId); } } + + private int computeBufferSize(int specifiedBufferSize) { + if (specifiedBufferSize != 0) { + return specifiedBufferSize; + } else if (isInputPcm) { + return getPcmDefaultBufferSize(); + } else if (useOffload) { + return getEncodedDefaultBufferSize(OFFLOAD_BUFFER_DURATION_US); + } else { // Passthrough + return getEncodedDefaultBufferSize(PASSTHROUGH_BUFFER_DURATION_US); + } + } + + private int getEncodedDefaultBufferSize(long bufferDurationUs) { + int rate = getMaximumEncodedRateBytesPerSecond(outputEncoding); + if (outputEncoding == C.ENCODING_AC3) { + rate *= AC3_BUFFER_MULTIPLICATION_FACTOR; + } + return (int) (bufferDurationUs * rate / C.MICROS_PER_SECOND); + } + + private int getPcmDefaultBufferSize() { + int minBufferSize = + AudioTrack.getMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding); + Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); + int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; + int minAppBufferSize = (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * outputPcmFrameSize; + int maxAppBufferSize = + Math.max( + minBufferSize, (int) durationUsToFrames(MAX_BUFFER_DURATION_US) * outputPcmFrameSize); + return Util.constrainValue(multipliedBufferSize, minAppBufferSize, maxAppBufferSize); + } + + @RequiresApi(21) + private static android.media.AudioAttributes getAudioTrackAttributesV21( + AudioAttributes audioAttributes, boolean tunneling) { + if (tunneling) { + return getAudioTrackTunnelingAttributesV21(); + } else { + return audioAttributes.getAudioAttributesV21(); + } + } + + @RequiresApi(21) + private static android.media.AudioAttributes getAudioTrackTunnelingAttributesV21() { + return new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) + .setFlags(android.media.AudioAttributes.FLAG_HW_AV_SYNC) + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .build(); + } } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index 4636e6fc14..e916ca549f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -63,7 +63,8 @@ public final class DefaultAudioSinkTest { new DefaultAudioSink( AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, new DefaultAudioSink.DefaultAudioProcessorChain(teeAudioProcessor), - /* enableConvertHighResIntPcmToFloat= */ false); + /* enableFloatOutput= */ false, + /* enableOffload= */ false); } @Test From 1f80cf15587ca31f9d9fd3a28e418aff7259519e Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 2 Jun 2020 10:58:37 +0100 Subject: [PATCH 0400/1052] Add API to get the format from the file extension This will allow to use the file extension in DefaultExtractorsFactory. PiperOrigin-RevId: 314296640 --- .../android/exoplayer2/util/FilenameUtil.java | 172 ++++++++++++++++++ .../exoplayer2/util/FilenameUtilTest.java | 46 +++++ .../hls/DefaultHlsExtractorFactory.java | 82 ++++----- 3 files changed, 256 insertions(+), 44 deletions(-) create mode 100644 library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java create mode 100644 library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java new file mode 100644 index 0000000000..234d855c4e --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java @@ -0,0 +1,172 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Filename related utility methods. */ +public final class FilenameUtil { + + /** + * File formats. One of {@link #FILE_FORMAT_UNKNOWN}, {@link #FILE_FORMAT_AC3}, {@link + * #FILE_FORMAT_AC4}, {@link #FILE_FORMAT_ADTS}, {@link #FILE_FORMAT_AMR}, {@link + * #FILE_FORMAT_FLAC}, {@link #FILE_FORMAT_FLV}, {@link #FILE_FORMAT_MATROSKA}, {@link + * #FILE_FORMAT_MP3}, {@link #FILE_FORMAT_MP4}, {@link #FILE_FORMAT_OGG}, {@link #FILE_FORMAT_PS}, + * {@link #FILE_FORMAT_TS}, {@link #FILE_FORMAT_WAV} and {@link #FILE_FORMAT_WEBVTT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + FILE_FORMAT_UNKNOWN, + FILE_FORMAT_AC3, + FILE_FORMAT_AC4, + FILE_FORMAT_ADTS, + FILE_FORMAT_AMR, + FILE_FORMAT_FLAC, + FILE_FORMAT_FLV, + FILE_FORMAT_MATROSKA, + FILE_FORMAT_MP3, + FILE_FORMAT_MP4, + FILE_FORMAT_OGG, + FILE_FORMAT_PS, + FILE_FORMAT_TS, + FILE_FORMAT_WAV, + FILE_FORMAT_WEBVTT + }) + public @interface FileFormat {} + /** Unknown file format. */ + public static final int FILE_FORMAT_UNKNOWN = -1; + /** File format for AC-3 and E-AC-3. */ + public static final int FILE_FORMAT_AC3 = 0; + /** File format for AC-4. */ + public static final int FILE_FORMAT_AC4 = 1; + /** File format for ADTS. */ + public static final int FILE_FORMAT_ADTS = 2; + /** File format for AMR. */ + public static final int FILE_FORMAT_AMR = 3; + /** File format for FLAC. */ + public static final int FILE_FORMAT_FLAC = 4; + /** File format for FLV. */ + public static final int FILE_FORMAT_FLV = 5; + /** File format for Matroska and WebM. */ + public static final int FILE_FORMAT_MATROSKA = 6; + /** File format for MP3. */ + public static final int FILE_FORMAT_MP3 = 7; + /** File format for MP4. */ + public static final int FILE_FORMAT_MP4 = 8; + /** File format for Ogg. */ + public static final int FILE_FORMAT_OGG = 9; + /** File format for MPEG-PS. */ + public static final int FILE_FORMAT_PS = 10; + /** File format for MPEG-TS. */ + public static final int FILE_FORMAT_TS = 11; + /** File format for WAV. */ + public static final int FILE_FORMAT_WAV = 12; + /** File format for WebVTT. */ + public static final int FILE_FORMAT_WEBVTT = 13; + + private static final String FILE_EXTENSION_AC3 = ".ac3"; + private static final String FILE_EXTENSION_EC3 = ".ec3"; + private static final String FILE_EXTENSION_AC4 = ".ac4"; + private static final String FILE_EXTENSION_ADTS = ".adts"; + private static final String FILE_EXTENSION_AAC = ".aac"; + private static final String FILE_EXTENSION_AMR = ".amr"; + private static final String FILE_EXTENSION_FLAC = ".flac"; + private static final String FILE_EXTENSION_FLV = ".flv"; + private static final String FILE_EXTENSION_PREFIX_MK = ".mk"; + private static final String FILE_EXTENSION_WEBM = ".webm"; + private static final String FILE_EXTENSION_PREFIX_OG = ".og"; + private static final String FILE_EXTENSION_OPUS = ".opus"; + private static final String FILE_EXTENSION_MP3 = ".mp3"; + private static final String FILE_EXTENSION_MP4 = ".mp4"; + private static final String FILE_EXTENSION_PREFIX_M4 = ".m4"; + private static final String FILE_EXTENSION_PREFIX_MP4 = ".mp4"; + private static final String FILE_EXTENSION_PREFIX_CMF = ".cmf"; + private static final String FILE_EXTENSION_PS = ".ps"; + private static final String FILE_EXTENSION_MPEG = ".mpeg"; + private static final String FILE_EXTENSION_MPG = ".mpg"; + private static final String FILE_EXTENSION_M2P = ".m2p"; + private static final String FILE_EXTENSION_TS = ".ts"; + private static final String FILE_EXTENSION_PREFIX_TS = ".ts"; + private static final String FILE_EXTENSION_WAV = ".wav"; + private static final String FILE_EXTENSION_WAVE = ".wave"; + private static final String FILE_EXTENSION_VTT = ".vtt"; + private static final String FILE_EXTENSION_WEBVTT = ".webvtt"; + + private FilenameUtil() {} + + /** + * Returns the {@link FileFormat} corresponding to the extension of the provided {@code filename}. + */ + @FileFormat + public static int getFormatFromExtension(String filename) { + if (filename.endsWith(FILE_EXTENSION_AC3) || filename.endsWith(FILE_EXTENSION_EC3)) { + return FILE_FORMAT_AC3; + } else if (filename.endsWith(FILE_EXTENSION_AC4)) { + return FILE_FORMAT_AC4; + } else if (filename.endsWith(FILE_EXTENSION_ADTS) || filename.endsWith(FILE_EXTENSION_AAC)) { + return FILE_FORMAT_ADTS; + } else if (filename.endsWith(FILE_EXTENSION_AMR)) { + return FILE_FORMAT_AMR; + } else if (filename.endsWith(FILE_EXTENSION_FLAC)) { + return FILE_FORMAT_FLAC; + } else if (filename.endsWith(FILE_EXTENSION_FLV)) { + return FILE_FORMAT_FLV; + } else if (filename.startsWith( + FILE_EXTENSION_PREFIX_MK, + /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_MK.length() + 1)) + || filename.endsWith(FILE_EXTENSION_WEBM)) { + return FILE_FORMAT_MATROSKA; + } else if (filename.endsWith(FILE_EXTENSION_MP3)) { + return FILE_FORMAT_MP3; + } else if (filename.endsWith(FILE_EXTENSION_MP4) + || filename.startsWith( + FILE_EXTENSION_PREFIX_M4, + /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_M4.length() + 1)) + || filename.startsWith( + FILE_EXTENSION_PREFIX_MP4, + /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_MP4.length() + 1)) + || filename.startsWith( + FILE_EXTENSION_PREFIX_CMF, + /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_CMF.length() + 1))) { + return FILE_FORMAT_MP4; + } else if (filename.startsWith( + FILE_EXTENSION_PREFIX_OG, + /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_OG.length() + 1)) + || filename.endsWith(FILE_EXTENSION_OPUS)) { + return FILE_FORMAT_OGG; + } else if (filename.endsWith(FILE_EXTENSION_PS) + || filename.endsWith(FILE_EXTENSION_MPEG) + || filename.endsWith(FILE_EXTENSION_MPG) + || filename.endsWith(FILE_EXTENSION_M2P)) { + return FILE_FORMAT_PS; + } else if (filename.endsWith(FILE_EXTENSION_TS) + || filename.startsWith( + FILE_EXTENSION_PREFIX_TS, + /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_TS.length() + 1))) { + return FILE_FORMAT_TS; + } else if (filename.endsWith(FILE_EXTENSION_WAV) || filename.endsWith(FILE_EXTENSION_WAVE)) { + return FILE_FORMAT_WAV; + } else if (filename.endsWith(FILE_EXTENSION_VTT) || filename.endsWith(FILE_EXTENSION_WEBVTT)) { + return FILE_FORMAT_WEBVTT; + } else { + return FILE_FORMAT_UNKNOWN; + } + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java new file mode 100644 index 0000000000..89270e0f33 --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MATROSKA; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP3; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_UNKNOWN; +import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link FilenameUtilTest}. */ +@RunWith(AndroidJUnit4.class) +public class FilenameUtilTest { + + @Test + public void getFormatFromExtension_withExtension_returnsExpectedFormat() { + assertThat(getFormatFromExtension("filename.mp3")).isEqualTo(FILE_FORMAT_MP3); + } + + @Test + public void getFormatFromExtension_withExtensionPrefix_returnsExpectedFormat() { + assertThat(getFormatFromExtension("filename.mka")).isEqualTo(FILE_FORMAT_MATROSKA); + } + + @Test + public void getFormatFromExtension_unknownExtension_returnsUnknownFormat() { + assertThat(getFormatFromExtension("filename.unknown")).isEqualTo(FILE_FORMAT_UNKNOWN); + } +} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 2ba2cd83af..5df0f88692 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -15,6 +15,15 @@ */ package com.google.android.exoplayer2.source.hls; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC3; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC4; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_ADTS; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP3; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP4; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_TS; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_WEBVTT; +import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; + import android.net.Uri; import android.text.TextUtils; import androidx.annotation.Nullable; @@ -30,6 +39,7 @@ import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.FilenameUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.EOFException; @@ -43,20 +53,6 @@ import java.util.Map; */ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { - public static final String AAC_FILE_EXTENSION = ".aac"; - public static final String AC3_FILE_EXTENSION = ".ac3"; - public static final String EC3_FILE_EXTENSION = ".ec3"; - public static final String AC4_FILE_EXTENSION = ".ac4"; - public static final String MP3_FILE_EXTENSION = ".mp3"; - public static final String MP4_FILE_EXTENSION = ".mp4"; - public static final String M4_FILE_EXTENSION_PREFIX = ".m4"; - public static final String MP4_FILE_EXTENSION_PREFIX = ".mp4"; - public static final String CMF_FILE_EXTENSION_PREFIX = ".cmf"; - public static final String TS_FILE_EXTENSION = ".ts"; - public static final String TS_FILE_EXTENSION_PREFIX = ".ts"; - public static final String VTT_FILE_EXTENSION = ".vtt"; - public static final String WEBVTT_FILE_EXTENSION = ".webvtt"; - @DefaultTsPayloadReaderFactory.Flags private final int payloadReaderFactoryFlags; private final boolean exposeCea608WhenMissingDeclarations; @@ -196,39 +192,37 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { Format format, @Nullable List muxedCaptionFormats, TimestampAdjuster timestampAdjuster) { - String lastPathSegment = uri.getLastPathSegment(); - if (lastPathSegment == null) { - lastPathSegment = ""; - } - if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType) - || lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) - || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { + if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType)) { return new WebvttExtractor(format.language, timestampAdjuster); - } else if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { - return new AdtsExtractor(); - } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) - || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { - return new Ac3Extractor(); - } else if (lastPathSegment.endsWith(AC4_FILE_EXTENSION)) { - return new Ac4Extractor(); - } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { - return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); - } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION) - || lastPathSegment.startsWith(M4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4) - || lastPathSegment.startsWith(MP4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5) - || lastPathSegment.startsWith(CMF_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5)) { - return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); - } else if (lastPathSegment.endsWith(TS_FILE_EXTENSION) - || lastPathSegment.startsWith(TS_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4)) { - return createTsExtractor( - payloadReaderFactoryFlags, - exposeCea608WhenMissingDeclarations, - format, - muxedCaptionFormats, - timestampAdjuster); - } else { + } + String filename = uri.getLastPathSegment(); + if (filename == null) { return null; } + @FilenameUtil.FileFormat int fileFormat = getFormatFromExtension(filename); + switch (fileFormat) { + case FILE_FORMAT_WEBVTT: + return new WebvttExtractor(format.language, timestampAdjuster); + case FILE_FORMAT_ADTS: + return new AdtsExtractor(); + case FILE_FORMAT_AC3: + return new Ac3Extractor(); + case FILE_FORMAT_AC4: + return new Ac4Extractor(); + case FILE_FORMAT_MP3: + return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); + case FILE_FORMAT_MP4: + return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); + case FILE_FORMAT_TS: + return createTsExtractor( + payloadReaderFactoryFlags, + exposeCea608WhenMissingDeclarations, + format, + muxedCaptionFormats, + timestampAdjuster); + default: + return null; + } } private static TsExtractor createTsExtractor( From 9699889569cffc3a68707ab61451b0e3a913ad4b Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 2 Jun 2020 11:00:43 +0100 Subject: [PATCH 0401/1052] Assert that a negative extractor sniff never advances the read position Extractor#sniff() javadoc says: "If true is returned, the input's reading position may have been modified. Otherwise, only its peek position may have been modified." https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/extractor/Extractor.html#sniff-com.google.android.exoplayer2.extractor.ExtractorInput- PiperOrigin-RevId: 314296922 --- .../android/exoplayer2/source/BundledExtractorsAdapter.java | 4 +++- .../google/android/exoplayer2/testutil/ExtractorAsserts.java | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java index f8764585aa..4b487bfbdf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java @@ -54,7 +54,8 @@ import java.io.IOException; public void init( DataReader dataReader, Uri uri, long position, long length, ExtractorOutput output) throws IOException { - extractorInput = new DefaultExtractorInput(dataReader, position, length); + ExtractorInput extractorInput = new DefaultExtractorInput(dataReader, position, length); + this.extractorInput = extractorInput; if (extractor != null) { return; } @@ -70,6 +71,7 @@ import java.io.IOException; } catch (EOFException e) { // Do nothing. } finally { + Assertions.checkState(this.extractor != null || extractorInput.getPosition() == position); extractorInput.resetPeekPosition(); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java index 1377ed733e..da585ee4f0 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java @@ -155,9 +155,13 @@ public final class ExtractorAsserts { */ public static void assertSniff( Extractor extractor, FakeExtractorInput input, boolean expectedResult) throws IOException { + long originalPosition = input.getPosition(); while (true) { try { assertThat(extractor.sniff(input)).isEqualTo(expectedResult); + if (!expectedResult) { + assertThat(input.getPosition()).isEqualTo(originalPosition); + } return; } catch (SimulatedIOException e) { // Ignore. From ed7db116c0e7aa891876de577e9c21eba6407b71 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 2 Jun 2020 12:13:20 +0100 Subject: [PATCH 0402/1052] Finalize release notes for 2.11.5 PiperOrigin-RevId: 314304928 --- RELEASENOTES.md | 54 ++++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1c669a18fd..2b098dda40 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,9 +12,6 @@ * Add opt-in to verify correct thread usage with `SimpleExoPlayer.setThrowsWhenUsingWrongThread(true)` ([#4463](https://github.com/google/ExoPlayer/issues/4463)). - * Fix bug where `PlayerMessages` throw an exception after `MediaSources` - are removed from the playlist - ([#7278](https://github.com/google/ExoPlayer/issues/7278)). * Add playbackPositionUs parameter to 'LoadControl.shouldContinueLoading'. * The `DefaultLoadControl` default minimum buffer is set to 50 seconds, equal to the default maximum buffer. `DefaultLoadControl` applies the @@ -175,6 +172,8 @@ `exo_playback_control_view.xml` from resource. * Move logic of prev, next, fast forward and rewind to ControlDispatcher ([#6926](https://github.com/google/ExoPlayer/issues/6926)). + * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog + ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Metadata: Add minimal DVB Application Information Table (AIT) support ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue @@ -183,32 +182,19 @@ * Change the order of extractors for sniffing to reduce start-up latency in `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` ([#6410](https://github.com/google/ExoPlayer/issues/6410)). -* IMA extension: - * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs - ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes - several issues involving preloading and handling of ad loading error - cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), - [#5006](https://github.com/google/ExoPlayer/issues/5006), - [#6030](https://github.com/google/ExoPlayer/issues/6030), - [#6097](https://github.com/google/ExoPlayer/issues/6097), - [#6425](https://github.com/google/ExoPlayer/issues/6425), - [#6967](https://github.com/google/ExoPlayer/issues/6967), - [#7041](https://github.com/google/ExoPlayer/issues/7041), - [#7161](https://github.com/google/ExoPlayer/issues/7161), - [#7212](https://github.com/google/ExoPlayer/issues/7212), - [#7340](https://github.com/google/ExoPlayer/issues/7340)). - * Add support for timing out ad preloading, to avoid playback getting - stuck if an ad group unexpectedly fails to load - ([#5444](https://github.com/google/ExoPlayer/issues/5444), - [#5966](https://github.com/google/ExoPlayer/issues/5966), - [#7002](https://github.com/google/ExoPlayer/issues/7002)). * Add Guava dependency. -### 2.11.5 (not yet released) ### +### 2.11.5 (2020-06-03) ### * Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). +* Fix bug where `PlayerMessages` throw an exception after `MediaSources` + are removed from the playlist + ([#7278](https://github.com/google/ExoPlayer/issues/7278)). +* Fix "Not allowed to start service" `IllegalStateException` in + `DownloadService` + ([#7306](https://github.com/google/ExoPlayer/issues/7306)). * Ads: * Fix `AdsMediaSource` child `MediaSource`s not being released. * DASH: @@ -231,13 +217,31 @@ * Fix `DefaultTimeBar` to respect touch transformations ([#7303](https://github.com/google/ExoPlayer/issues/7303)). * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. - * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog - ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Text: * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles. * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct color. +* IMA extension: + * Upgrade to IMA SDK version 3.19.0, and migrate to new + preloading APIs + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes + several issues involving preloading and handling of ad loading error + cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), + [#5006](https://github.com/google/ExoPlayer/issues/5006), + [#6030](https://github.com/google/ExoPlayer/issues/6030), + [#6097](https://github.com/google/ExoPlayer/issues/6097), + [#6425](https://github.com/google/ExoPlayer/issues/6425), + [#6967](https://github.com/google/ExoPlayer/issues/6967), + [#7041](https://github.com/google/ExoPlayer/issues/7041), + [#7161](https://github.com/google/ExoPlayer/issues/7161), + [#7212](https://github.com/google/ExoPlayer/issues/7212), + [#7340](https://github.com/google/ExoPlayer/issues/7340)). + * Add support for timing out ad preloading, to avoid playback getting + stuck if an ad group unexpectedly fails to load + ([#5444](https://github.com/google/ExoPlayer/issues/5444), + [#5966](https://github.com/google/ExoPlayer/issues/5966), + [#7002](https://github.com/google/ExoPlayer/issues/7002)). * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when From 7bc5fa855f774ce198e23a455fec751b7743486f Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 2 Jun 2020 13:32:33 +0100 Subject: [PATCH 0403/1052] Don't keep pending preferred queue size during cancelation. Before loading a new chunk, the player will call reevaluateBuffer anyway, so we don't have to do this directly after cancelation. This simplifies some logic because we can remove the pending queue size variable. PiperOrigin-RevId: 314313268 --- .../exoplayer2/source/hls/HlsSampleStreamWrapper.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 79f4d975fc..28aba78558 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -146,7 +146,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private @MonotonicNonNull Format upstreamTrackFormat; @Nullable private Format downstreamTrackFormat; private boolean released; - private int pendingDiscardUpstreamQueueSize; // Tracks are complicated in HLS. See documentation of buildTracksFromSampleStreams for details. // Indexed by track (as exposed by this source). @@ -230,7 +229,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; handler = Util.createHandler(); lastSeekPositionUs = positionUs; pendingResetPositionUs = positionUs; - pendingDiscardUpstreamQueueSize = C.LENGTH_UNSET; } public void continuePreparing() { @@ -708,7 +706,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return; } if (loader.isLoading()) { - pendingDiscardUpstreamQueueSize = preferredQueueSize; loader.cancelLoading(); } else { discardUpstream(preferredQueueSize); @@ -769,10 +766,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; loadable.startTimeUs, loadable.endTimeUs); if (!released) { - if (pendingDiscardUpstreamQueueSize != C.LENGTH_UNSET) { - discardUpstream(pendingDiscardUpstreamQueueSize); - pendingDiscardUpstreamQueueSize = C.LENGTH_UNSET; - } else { + if (isPendingReset() || enabledTrackGroupCount == 0) { resetSampleQueues(); } if (enabledTrackGroupCount > 0) { @@ -891,7 +885,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (newQueueSize == C.LENGTH_UNSET) { return; } - + long endTimeUs = getLastMediaChunk().endTimeUs; HlsMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); if (mediaChunks.isEmpty()) { From 5b06d89467674286b3ae1824904677f030cd5972 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Tue, 2 Jun 2020 14:53:15 +0100 Subject: [PATCH 0404/1052] Check for section_syntax_indicator in TS tables Issue:#7325 PiperOrigin-RevId: 314321914 --- .../exoplayer2/extractor/ts/TsExtractor.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 5e85a80a5d..7b470ad570 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -463,10 +463,15 @@ public final class TsExtractor implements Extractor { // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. return; } - // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12), - // transport_stream_id (16), reserved (2), version_number (5), current_next_indicator (1), - // section_number (8), last_section_number (8) - sectionData.skipBytes(7); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.5. + return; + } + // section_length(8), transport_stream_id (16), reserved (2), version_number (5), + // current_next_indicator (1), section_number (8), last_section_number (8) + sectionData.skipBytes(6); int programCount = sectionData.bytesLeft() / 4; for (int i = 0; i < programCount; i++) { @@ -539,8 +544,14 @@ public final class TsExtractor implements Extractor { timestampAdjusters.add(timestampAdjuster); } - // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12) - sectionData.skipBytes(2); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.9. + return; + } + // section_length(8) + sectionData.skipBytes(1); int programNumber = sectionData.readUnsignedShort(); // Skip 3 bytes (24 bits), including: From 720f0012a845d72be59d92735effe53045c81bed Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 2 Jun 2020 22:48:15 +0100 Subject: [PATCH 0405/1052] AudioTrackPositionTracker: Prevent negative timestamps Issue: #7456 PiperOrigin-RevId: 314408767 --- RELEASENOTES.md | 6 ++++-- .../android/exoplayer2/audio/AudioTrackPositionTracker.java | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2b098dda40..8adb4effcf 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -195,8 +195,9 @@ * Fix "Not allowed to start service" `IllegalStateException` in `DownloadService` ([#7306](https://github.com/google/ExoPlayer/issues/7306)). -* Ads: - * Fix `AdsMediaSource` child `MediaSource`s not being released. +* Fix issue in `AudioTrackPositionTracker` that could cause negative positions + to be reported at the start of playback and immediately after seeking + ([#7456](https://github.com/google/ExoPlayer/issues/7456). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as @@ -242,6 +243,7 @@ ([#5444](https://github.com/google/ExoPlayer/issues/5444), [#5966](https://github.com/google/ExoPlayer/issues/5966), [#7002](https://github.com/google/ExoPlayer/issues/7002)). + * Fix `AdsMediaSource` child `MediaSource`s not being released. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index b3e232df22..c88c800256 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -206,6 +206,7 @@ import java.lang.reflect.Method; hasData = false; stopTimestampUs = C.TIME_UNSET; forceResetWorkaroundTimeMs = C.TIME_UNSET; + lastLatencySampleTimeUs = 0; latencyUs = 0; } @@ -239,7 +240,7 @@ import java.lang.reflect.Method; positionUs = systemTimeUs + smoothedPlayheadOffsetUs; } if (!sourceEnded) { - positionUs -= latencyUs; + positionUs = Math.max(0, positionUs - latencyUs); } return positionUs; } @@ -353,7 +354,7 @@ import java.lang.reflect.Method; } /** - * Resets the position tracker. Should be called when the audio track previous passed to {@link + * Resets the position tracker. Should be called when the audio track previously passed to {@link * #setAudioTrack(AudioTrack, int, int, int)} is no longer in use. */ public void reset() { From 3904f6778a926ac69c338499f38391c0b63ace2b Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 2 Jun 2020 23:42:26 +0100 Subject: [PATCH 0406/1052] Fix position jank after pausing and seeking Issue: #6901 PiperOrigin-RevId: 314418536 --- RELEASENOTES.md | 3 ++ .../audio/AudioTimestampPoller.java | 10 ++-- .../audio/AudioTrackPositionTracker.java | 46 ++++++++++++++++--- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8adb4effcf..d654ab9522 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -186,6 +186,9 @@ ### 2.11.5 (2020-06-03) ### +* Improve the smoothness of video playback immediately after starting, seeking + or resuming a playback + ([#6901](https://github.com/google/ExoPlayer/issues/6901)). * Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java index 9e870735f2..6b34d7f13d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java @@ -38,7 +38,7 @@ import java.lang.annotation.RetentionPolicy; * *

      If {@link #hasTimestamp()} returns {@code true}, call {@link #getTimestampSystemTimeUs()} to * get the system time at which the latest timestamp was sampled and {@link - * #getTimestampPositionFrames()} to get its position in frames. If {@link #isTimestampAdvancing()} + * #getTimestampPositionFrames()} to get its position in frames. If {@link #hasAdvancingTimestamp()} * returns {@code true}, the caller should assume that the timestamp has been increasing in real * time since it was sampled. Otherwise, it may be stationary. * @@ -69,7 +69,7 @@ import java.lang.annotation.RetentionPolicy; private static final int STATE_ERROR = 4; /** The polling interval for {@link #STATE_INITIALIZING} and {@link #STATE_TIMESTAMP}. */ - private static final int FAST_POLL_INTERVAL_US = 5_000; + private static final int FAST_POLL_INTERVAL_US = 10_000; /** * The polling interval for {@link #STATE_TIMESTAMP_ADVANCING} and {@link #STATE_NO_TIMESTAMP}. */ @@ -111,7 +111,7 @@ import java.lang.annotation.RetentionPolicy; * timestamp is available via {@link #getTimestampSystemTimeUs()} and {@link * #getTimestampPositionFrames()}, and the caller should call {@link #acceptTimestamp()} if the * timestamp was valid, or {@link #rejectTimestamp()} otherwise. The values returned by {@link - * #hasTimestamp()} and {@link #isTimestampAdvancing()} may be updated. + * #hasTimestamp()} and {@link #hasAdvancingTimestamp()} may be updated. * * @param systemTimeUs The current system time, in microseconds. * @return Whether the timestamp was updated. @@ -202,12 +202,12 @@ import java.lang.annotation.RetentionPolicy; } /** - * Returns whether the timestamp appears to be advancing. If {@code true}, call {@link + * Returns whether this instance has an advancing timestamp. If {@code true}, call {@link * #getTimestampSystemTimeUs()} and {@link #getTimestampSystemTimeUs()} to access the timestamp. A * current position for the track can be extrapolated based on elapsed real time since the system * time at which the timestamp was sampled. */ - public boolean isTimestampAdvancing() { + public boolean hasAdvancingTimestamp() { return state == STATE_TIMESTAMP_ADVANCING; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index c88c800256..d15fe44fc0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -123,6 +123,8 @@ import java.lang.reflect.Method; *

      This is a fail safe that should not be required on correctly functioning devices. */ private static final long MAX_LATENCY_US = 5 * C.MICROS_PER_SECOND; + /** The duration of time used to smooth over an adjustment between position sampling modes. */ + private static final long MODE_SWITCH_SMOOTHING_DURATION_US = C.MICROS_PER_SECOND; private static final long FORCE_RESET_WORKAROUND_TIMEOUT_MS = 200; @@ -160,6 +162,15 @@ import java.lang.reflect.Method; private long stopPlaybackHeadPosition; private long endPlaybackHeadPosition; + // Results from the previous call to getCurrentPositionUs. + private long lastPositionUs; + private long lastSystemTimeUs; + private boolean lastSampleUsedGetTimestampMode; + + // Results from the last call to getCurrentPositionUs that used a different sample mode. + private long previousModePositionUs; + private long previousModeSystemTimeUs; + /** * Creates a new audio track position tracker. * @@ -218,18 +229,16 @@ import java.lang.reflect.Method; // If the device supports it, use the playback timestamp from AudioTrack.getTimestamp. // Otherwise, derive a smoothed position by sampling the track's frame position. long systemTimeUs = System.nanoTime() / 1000; + long positionUs; AudioTimestampPoller audioTimestampPoller = Assertions.checkNotNull(this.audioTimestampPoller); - if (audioTimestampPoller.hasTimestamp()) { + boolean useGetTimestampMode = audioTimestampPoller.hasAdvancingTimestamp(); + if (useGetTimestampMode) { // Calculate the speed-adjusted position using the timestamp (which may be in the future). long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); long timestampPositionUs = framesToDurationUs(timestampPositionFrames); - if (!audioTimestampPoller.isTimestampAdvancing()) { - return timestampPositionUs; - } long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs(); - return timestampPositionUs + elapsedSinceTimestampUs; + positionUs = timestampPositionUs + elapsedSinceTimestampUs; } else { - long positionUs; if (playheadOffsetCount == 0) { // The AudioTrack has started, but we don't have any samples to compute a smoothed position. positionUs = getPlaybackHeadPositionUs(); @@ -242,8 +251,29 @@ import java.lang.reflect.Method; if (!sourceEnded) { positionUs = Math.max(0, positionUs - latencyUs); } - return positionUs; } + + if (lastSampleUsedGetTimestampMode != useGetTimestampMode) { + // We've switched sampling mode. + previousModeSystemTimeUs = lastSystemTimeUs; + previousModePositionUs = lastPositionUs; + } + long elapsedSincePreviousModeUs = systemTimeUs - previousModeSystemTimeUs; + if (elapsedSincePreviousModeUs < MODE_SWITCH_SMOOTHING_DURATION_US) { + // Use a ramp to smooth between the old mode and the new one to avoid introducing a sudden + // jump if the two modes disagree. + long previousModeProjectedPositionUs = previousModePositionUs + elapsedSincePreviousModeUs; + // A ramp consisting of 1000 points distributed over MODE_SWITCH_SMOOTHING_DURATION_US. + long rampPoint = (elapsedSincePreviousModeUs * 1000) / MODE_SWITCH_SMOOTHING_DURATION_US; + positionUs *= rampPoint; + positionUs += (1000 - rampPoint) * previousModeProjectedPositionUs; + positionUs /= 1000; + } + + lastSystemTimeUs = systemTimeUs; + lastPositionUs = positionUs; + lastSampleUsedGetTimestampMode = useGetTimestampMode; + return positionUs; } /** Starts position tracking. Must be called immediately before {@link AudioTrack#play()}. */ @@ -458,6 +488,8 @@ import java.lang.reflect.Method; playheadOffsetCount = 0; nextPlayheadOffsetIndex = 0; lastPlayheadSampleTimeUs = 0; + lastSystemTimeUs = 0; + previousModeSystemTimeUs = 0; } /** From 79acadcc893675afdfa01f10f47d05f7736522ef Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Tue, 2 Jun 2020 14:53:15 +0100 Subject: [PATCH 0407/1052] Check for section_syntax_indicator in TS tables Issue:#7325 PiperOrigin-RevId: 314321914 --- .../exoplayer2/extractor/ts/TsExtractor.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 2cd7398d7c..2bd5b12551 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -460,10 +460,15 @@ public final class TsExtractor implements Extractor { // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. return; } - // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12), - // transport_stream_id (16), reserved (2), version_number (5), current_next_indicator (1), - // section_number (8), last_section_number (8) - sectionData.skipBytes(7); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.5. + return; + } + // section_length(8), transport_stream_id (16), reserved (2), version_number (5), + // current_next_indicator (1), section_number (8), last_section_number (8) + sectionData.skipBytes(6); int programCount = sectionData.bytesLeft() / 4; for (int i = 0; i < programCount; i++) { @@ -535,8 +540,14 @@ public final class TsExtractor implements Extractor { timestampAdjusters.add(timestampAdjuster); } - // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12) - sectionData.skipBytes(2); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.9. + return; + } + // section_length(8) + sectionData.skipBytes(1); int programNumber = sectionData.readUnsignedShort(); // Skip 3 bytes (24 bits), including: From fb011e66a6ff164c76dc8f17b4df152a1848835b Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 2 Jun 2020 22:48:15 +0100 Subject: [PATCH 0408/1052] AudioTrackPositionTracker: Prevent negative timestamps Issue: #7456 PiperOrigin-RevId: 314408767 --- RELEASENOTES.md | 6 ++++-- .../android/exoplayer2/audio/AudioTrackPositionTracker.java | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e953578ee2..5c37ac1245 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -11,8 +11,9 @@ * Fix "Not allowed to start service" `IllegalStateException` in `DownloadService` ([#7306](https://github.com/google/ExoPlayer/issues/7306)). -* Ads: - * Fix `AdsMediaSource` child `MediaSource`s not being released. +* Fix issue in `AudioTrackPositionTracker` that could cause negative positions + to be reported at the start of playback and immediately after seeking + ([#7456](https://github.com/google/ExoPlayer/issues/7456). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as @@ -58,6 +59,7 @@ ([#5444](https://github.com/google/ExoPlayer/issues/5444), [#5966](https://github.com/google/ExoPlayer/issues/5966), [#7002](https://github.com/google/ExoPlayer/issues/7002)). + * Fix `AdsMediaSource` child `MediaSource`s not being released. * Cronet extension: Default to using the Cronet implementation in Google Play Services rather than Cronet Embedded. This allows Cronet to be used with a negligible increase in application size, compared to approximately 8MB when diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index 4ee70bd813..f227a6f3d8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -206,6 +206,7 @@ import java.lang.reflect.Method; hasData = false; stopTimestampUs = C.TIME_UNSET; forceResetWorkaroundTimeMs = C.TIME_UNSET; + lastLatencySampleTimeUs = 0; latencyUs = 0; } @@ -239,7 +240,7 @@ import java.lang.reflect.Method; positionUs = systemTimeUs + smoothedPlayheadOffsetUs; } if (!sourceEnded) { - positionUs -= latencyUs; + positionUs = Math.max(0, positionUs - latencyUs); } return positionUs; } @@ -353,7 +354,7 @@ import java.lang.reflect.Method; } /** - * Resets the position tracker. Should be called when the audio track previous passed to {@link + * Resets the position tracker. Should be called when the audio track previously passed to {@link * #setAudioTrack(AudioTrack, int, int, int)} is no longer in use. */ public void reset() { From a818049143a24e75d1d395221c45fc90a87f47c1 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 2 Jun 2020 23:42:26 +0100 Subject: [PATCH 0409/1052] Fix position jank after pausing and seeking Issue: #6901 PiperOrigin-RevId: 314418536 --- RELEASENOTES.md | 3 ++ .../audio/AudioTimestampPoller.java | 10 ++-- .../audio/AudioTrackPositionTracker.java | 46 ++++++++++++++++--- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5c37ac1245..3323402192 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,9 @@ ### 2.11.5 (2020-06-03) ### +* Improve the smoothness of video playback immediately after starting, seeking + or resuming a playback + ([#6901](https://github.com/google/ExoPlayer/issues/6901)). * Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java index 0564591f1f..200c917954 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java @@ -37,7 +37,7 @@ import java.lang.annotation.RetentionPolicy; * *

      If {@link #hasTimestamp()} returns {@code true}, call {@link #getTimestampSystemTimeUs()} to * get the system time at which the latest timestamp was sampled and {@link - * #getTimestampPositionFrames()} to get its position in frames. If {@link #isTimestampAdvancing()} + * #getTimestampPositionFrames()} to get its position in frames. If {@link #hasAdvancingTimestamp()} * returns {@code true}, the caller should assume that the timestamp has been increasing in real * time since it was sampled. Otherwise, it may be stationary. * @@ -68,7 +68,7 @@ import java.lang.annotation.RetentionPolicy; private static final int STATE_ERROR = 4; /** The polling interval for {@link #STATE_INITIALIZING} and {@link #STATE_TIMESTAMP}. */ - private static final int FAST_POLL_INTERVAL_US = 5_000; + private static final int FAST_POLL_INTERVAL_US = 10_000; /** * The polling interval for {@link #STATE_TIMESTAMP_ADVANCING} and {@link #STATE_NO_TIMESTAMP}. */ @@ -110,7 +110,7 @@ import java.lang.annotation.RetentionPolicy; * timestamp is available via {@link #getTimestampSystemTimeUs()} and {@link * #getTimestampPositionFrames()}, and the caller should call {@link #acceptTimestamp()} if the * timestamp was valid, or {@link #rejectTimestamp()} otherwise. The values returned by {@link - * #hasTimestamp()} and {@link #isTimestampAdvancing()} may be updated. + * #hasTimestamp()} and {@link #hasAdvancingTimestamp()} may be updated. * * @param systemTimeUs The current system time, in microseconds. * @return Whether the timestamp was updated. @@ -200,12 +200,12 @@ import java.lang.annotation.RetentionPolicy; } /** - * Returns whether the timestamp appears to be advancing. If {@code true}, call {@link + * Returns whether this instance has an advancing timestamp. If {@code true}, call {@link * #getTimestampSystemTimeUs()} and {@link #getTimestampSystemTimeUs()} to access the timestamp. A * current position for the track can be extrapolated based on elapsed real time since the system * time at which the timestamp was sampled. */ - public boolean isTimestampAdvancing() { + public boolean hasAdvancingTimestamp() { return state == STATE_TIMESTAMP_ADVANCING; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index f227a6f3d8..d944edc197 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -123,6 +123,8 @@ import java.lang.reflect.Method; *

      This is a fail safe that should not be required on correctly functioning devices. */ private static final long MAX_LATENCY_US = 5 * C.MICROS_PER_SECOND; + /** The duration of time used to smooth over an adjustment between position sampling modes. */ + private static final long MODE_SWITCH_SMOOTHING_DURATION_US = C.MICROS_PER_SECOND; private static final long FORCE_RESET_WORKAROUND_TIMEOUT_MS = 200; @@ -160,6 +162,15 @@ import java.lang.reflect.Method; private long stopPlaybackHeadPosition; private long endPlaybackHeadPosition; + // Results from the previous call to getCurrentPositionUs. + private long lastPositionUs; + private long lastSystemTimeUs; + private boolean lastSampleUsedGetTimestampMode; + + // Results from the last call to getCurrentPositionUs that used a different sample mode. + private long previousModePositionUs; + private long previousModeSystemTimeUs; + /** * Creates a new audio track position tracker. * @@ -218,18 +229,16 @@ import java.lang.reflect.Method; // If the device supports it, use the playback timestamp from AudioTrack.getTimestamp. // Otherwise, derive a smoothed position by sampling the track's frame position. long systemTimeUs = System.nanoTime() / 1000; + long positionUs; AudioTimestampPoller audioTimestampPoller = Assertions.checkNotNull(this.audioTimestampPoller); - if (audioTimestampPoller.hasTimestamp()) { + boolean useGetTimestampMode = audioTimestampPoller.hasAdvancingTimestamp(); + if (useGetTimestampMode) { // Calculate the speed-adjusted position using the timestamp (which may be in the future). long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); long timestampPositionUs = framesToDurationUs(timestampPositionFrames); - if (!audioTimestampPoller.isTimestampAdvancing()) { - return timestampPositionUs; - } long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs(); - return timestampPositionUs + elapsedSinceTimestampUs; + positionUs = timestampPositionUs + elapsedSinceTimestampUs; } else { - long positionUs; if (playheadOffsetCount == 0) { // The AudioTrack has started, but we don't have any samples to compute a smoothed position. positionUs = getPlaybackHeadPositionUs(); @@ -242,8 +251,29 @@ import java.lang.reflect.Method; if (!sourceEnded) { positionUs = Math.max(0, positionUs - latencyUs); } - return positionUs; } + + if (lastSampleUsedGetTimestampMode != useGetTimestampMode) { + // We've switched sampling mode. + previousModeSystemTimeUs = lastSystemTimeUs; + previousModePositionUs = lastPositionUs; + } + long elapsedSincePreviousModeUs = systemTimeUs - previousModeSystemTimeUs; + if (elapsedSincePreviousModeUs < MODE_SWITCH_SMOOTHING_DURATION_US) { + // Use a ramp to smooth between the old mode and the new one to avoid introducing a sudden + // jump if the two modes disagree. + long previousModeProjectedPositionUs = previousModePositionUs + elapsedSincePreviousModeUs; + // A ramp consisting of 1000 points distributed over MODE_SWITCH_SMOOTHING_DURATION_US. + long rampPoint = (elapsedSincePreviousModeUs * 1000) / MODE_SWITCH_SMOOTHING_DURATION_US; + positionUs *= rampPoint; + positionUs += (1000 - rampPoint) * previousModeProjectedPositionUs; + positionUs /= 1000; + } + + lastSystemTimeUs = systemTimeUs; + lastPositionUs = positionUs; + lastSampleUsedGetTimestampMode = useGetTimestampMode; + return positionUs; } /** Starts position tracking. Must be called immediately before {@link AudioTrack#play()}. */ @@ -458,6 +488,8 @@ import java.lang.reflect.Method; playheadOffsetCount = 0; nextPlayheadOffsetIndex = 0; lastPlayheadSampleTimeUs = 0; + lastSystemTimeUs = 0; + previousModeSystemTimeUs = 0; } /** From 7df99381c1be7df680c61f35cc3ec798c26ed1a7 Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 3 Jun 2020 12:46:26 +0100 Subject: [PATCH 0410/1052] Optimize extractors order using file extension PiperOrigin-RevId: 314508481 --- RELEASENOTES.md | 12 +- .../source/ProgressiveMediaSource.java | 2 +- .../extractor/DefaultExtractorsFactory.java | 171 +++++++++++++----- .../extractor/ExtractorsFactory.java | 10 + .../DefaultExtractorsFactoryTest.java | 86 ++++++--- 5 files changed, 209 insertions(+), 72 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d654ab9522..0f9a4b855d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -156,12 +156,17 @@ * HLS: * Add support for upstream discard including cancelation of ongoing load ([#6322](https://github.com/google/ExoPlayer/issues/6322)). -* MP3: - * Add `IndexSeeker` for accurate seeks in VBR streams +* Extractors: + * Add `IndexSeeker` for accurate seeks in VBR MP3 streams ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker is enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the `Mp3Extractor`. It may require to scan a significant portion of the file for seeking, which may be costly on large files. + * Change the order of extractors for sniffing to reduce start-up latency + in `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` + ([#6410](https://github.com/google/ExoPlayer/issues/6410)). + * Select first extractors based on the filename extension in + `DefaultExtractorsFactory`. * Testing * Add `TestExoPlayer`, a utility class with APIs to create `SimpleExoPlayer` instances with fake components for testing. @@ -179,9 +184,6 @@ * Cast extension: Implement playlist API and deprecate the old queue manipulation API. * Demo app: Retain previous position in list of samples. -* Change the order of extractors for sniffing to reduce start-up latency in - `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` - ([#6410](https://github.com/google/ExoPlayer/issues/6410)). * Add Guava dependency. ### 2.11.5 (2020-06-03) ### diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index 6c1d26cb07..fbb657a4e4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -276,7 +276,7 @@ public final class ProgressiveMediaSource extends BaseMediaSource return new ProgressiveMediaPeriod( playbackProperties.uri, dataSource, - extractorsFactory.createExtractors(), + extractorsFactory.createExtractors(playbackProperties.uri), drmSessionManager, loadableLoadErrorHandlingPolicy, createEventDispatcher(id), diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 9306a146d5..6f620c319e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -15,6 +15,23 @@ */ package com.google.android.exoplayer2.extractor; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC3; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC4; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_ADTS; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AMR; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_FLAC; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_FLV; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MATROSKA; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP3; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP4; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_OGG; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_PS; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_TS; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_UNKNOWN; +import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_WAV; +import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; + +import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor; @@ -32,8 +49,11 @@ import com.google.android.exoplayer2.extractor.ts.PsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.extractor.ts.TsPayloadReader; import com.google.android.exoplayer2.extractor.wav.WavExtractor; +import com.google.android.exoplayer2.util.FilenameUtil; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.List; /** * An {@link ExtractorsFactory} that provides an array of extractors for the following formats: @@ -63,6 +83,25 @@ import java.lang.reflect.Constructor; */ public final class DefaultExtractorsFactory implements ExtractorsFactory { + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + private static final int[] DEFAULT_EXTRACTOR_ORDER = + new int[] { + FILE_FORMAT_FLV, + FILE_FORMAT_FLAC, + FILE_FORMAT_WAV, + FILE_FORMAT_MP4, + FILE_FORMAT_AMR, + FILE_FORMAT_PS, + FILE_FORMAT_OGG, + FILE_FORMAT_TS, + FILE_FORMAT_MATROSKA, + FILE_FORMAT_ADTS, + FILE_FORMAT_AC3, + FILE_FORMAT_AC4, + FILE_FORMAT_MP3, + }; + @Nullable private static final Constructor FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR; @@ -240,48 +279,96 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { @Override public synchronized Extractor[] createExtractors() { - Extractor[] extractors = new Extractor[14]; - // Extractors order is optimized according to - // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. - extractors[0] = new FlvExtractor(); - if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { - try { - extractors[1] = FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(); - } catch (Exception e) { - // Should never happen. - throw new IllegalStateException("Unexpected error creating FLAC extractor", e); - } - } else { - extractors[1] = new FlacExtractor(coreFlacFlags); - } - extractors[2] = new WavExtractor(); - extractors[3] = new FragmentedMp4Extractor(fragmentedMp4Flags); - extractors[4] = new Mp4Extractor(mp4Flags); - extractors[5] = - new AmrExtractor( - amrFlags - | (constantBitrateSeekingEnabled - ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - : 0)); - extractors[6] = new PsExtractor(); - extractors[7] = new OggExtractor(); - extractors[8] = new TsExtractor(tsMode, tsFlags); - extractors[9] = new MatroskaExtractor(matroskaFlags); - extractors[10] = - new AdtsExtractor( - adtsFlags - | (constantBitrateSeekingEnabled - ? AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - : 0)); - extractors[11] = new Ac3Extractor(); - extractors[12] = new Ac4Extractor(); - extractors[13] = - new Mp3Extractor( - mp3Flags - | (constantBitrateSeekingEnabled - ? Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - : 0)); - return extractors; + return createExtractors(Uri.EMPTY); } + @Override + public synchronized Extractor[] createExtractors(Uri uri) { + List extractors = new ArrayList<>(/* initialCapacity= */ 14); + + String filename = uri.getLastPathSegment(); + @FilenameUtil.FileFormat + int extensionFormat = filename == null ? FILE_FORMAT_UNKNOWN : getFormatFromExtension(filename); + addExtractorsForFormat(extensionFormat, extractors); + + for (int format : DEFAULT_EXTRACTOR_ORDER) { + if (format != extensionFormat) { + addExtractorsForFormat(format, extractors); + } + } + + return extractors.toArray(new Extractor[extractors.size()]); + } + + private void addExtractorsForFormat( + @FilenameUtil.FileFormat int fileFormat, List extractors) { + switch (fileFormat) { + case FILE_FORMAT_AC3: + extractors.add(new Ac3Extractor()); + break; + case FILE_FORMAT_AC4: + extractors.add(new Ac4Extractor()); + break; + case FILE_FORMAT_ADTS: + extractors.add( + new AdtsExtractor( + adtsFlags + | (constantBitrateSeekingEnabled + ? AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0))); + break; + case FILE_FORMAT_AMR: + extractors.add( + new AmrExtractor( + amrFlags + | (constantBitrateSeekingEnabled + ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0))); + break; + case FILE_FORMAT_FLAC: + if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { + try { + extractors.add(FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance()); + } catch (Exception e) { + // Should never happen. + throw new IllegalStateException("Unexpected error creating FLAC extractor", e); + } + } else { + extractors.add(new FlacExtractor(coreFlacFlags)); + } + break; + case FILE_FORMAT_FLV: + extractors.add(new FlvExtractor()); + break; + case FILE_FORMAT_MATROSKA: + extractors.add(new MatroskaExtractor(matroskaFlags)); + break; + case FILE_FORMAT_MP3: + extractors.add( + new Mp3Extractor( + mp3Flags + | (constantBitrateSeekingEnabled + ? Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0))); + break; + case FILE_FORMAT_MP4: + extractors.add(new FragmentedMp4Extractor(fragmentedMp4Flags)); + extractors.add(new Mp4Extractor(mp4Flags)); + break; + case FILE_FORMAT_OGG: + extractors.add(new OggExtractor()); + break; + case FILE_FORMAT_PS: + extractors.add(new PsExtractor()); + break; + case FILE_FORMAT_TS: + extractors.add(new TsExtractor(tsMode, tsFlags)); + break; + case FILE_FORMAT_WAV: + extractors.add(new WavExtractor()); + break; + default: + break; + } + } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java index ee29f376a1..bbd43d8c5a 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java @@ -15,9 +15,19 @@ */ package com.google.android.exoplayer2.extractor; +import android.net.Uri; + /** Factory for arrays of {@link Extractor} instances. */ public interface ExtractorsFactory { /** Returns an array of new {@link Extractor} instances. */ Extractor[] createExtractors(); + + /** + * Returns an array of new {@link Extractor} instances to extract the stream corresponding to the + * provided {@link Uri}. + */ + default Extractor[] createExtractors(Uri uri) { + return createExtractors(); + } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java index b24c76d262..030c45e5b1 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.extractor; import static com.google.common.truth.Truth.assertThat; +import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor; @@ -42,34 +43,71 @@ import org.junit.runner.RunWith; public final class DefaultExtractorsFactoryTest { @Test - public void createExtractors_returnExpectedClasses() { + public void createExtractors_withoutUri_optimizesSniffingOrder() { DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); Extractor[] extractors = defaultExtractorsFactory.createExtractors(); - List> listCreatedExtractorClasses = new ArrayList<>(); + + List> extractorClasses = getExtractorClasses(extractors); + assertThat(extractorClasses.subList(0, 3)) + .containsExactly(FlvExtractor.class, FlacExtractor.class, WavExtractor.class) + .inOrder(); + assertThat(extractorClasses.subList(3, 5)) + .containsExactly(Mp4Extractor.class, FragmentedMp4Extractor.class); + assertThat(extractorClasses.subList(5, extractors.length)) + .containsExactly( + AmrExtractor.class, + PsExtractor.class, + OggExtractor.class, + TsExtractor.class, + MatroskaExtractor.class, + AdtsExtractor.class, + Ac3Extractor.class, + Ac4Extractor.class, + Mp3Extractor.class) + .inOrder(); + } + + @Test + public void createExtractors_withUri_startsWithExtractorsMatchingExtension() { + DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + + Extractor[] extractors = defaultExtractorsFactory.createExtractors(Uri.parse("test.mp4")); + + List> extractorClasses = getExtractorClasses(extractors); + assertThat(extractorClasses.subList(0, 2)) + .containsExactly(Mp4Extractor.class, FragmentedMp4Extractor.class); + } + + @Test + public void createExtractors_withUri_optimizesSniffingOrder() { + DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + + Extractor[] extractors = defaultExtractorsFactory.createExtractors(Uri.parse("test.mp4")); + + List> extractorClasses = getExtractorClasses(extractors); + assertThat(extractorClasses.subList(2, extractors.length)) + .containsExactly( + FlvExtractor.class, + FlacExtractor.class, + WavExtractor.class, + AmrExtractor.class, + PsExtractor.class, + OggExtractor.class, + TsExtractor.class, + MatroskaExtractor.class, + AdtsExtractor.class, + Ac3Extractor.class, + Ac4Extractor.class, + Mp3Extractor.class) + .inOrder(); + } + + private static List> getExtractorClasses(Extractor[] extractors) { + List> extractorClasses = new ArrayList<>(); for (Extractor extractor : extractors) { - listCreatedExtractorClasses.add(extractor.getClass()); + extractorClasses.add(extractor.getClass()); } - - Class[] expectedExtractorClassses = - new Class[] { - MatroskaExtractor.class, - FragmentedMp4Extractor.class, - Mp4Extractor.class, - Mp3Extractor.class, - AdtsExtractor.class, - Ac3Extractor.class, - TsExtractor.class, - FlvExtractor.class, - OggExtractor.class, - PsExtractor.class, - WavExtractor.class, - AmrExtractor.class, - Ac4Extractor.class, - FlacExtractor.class - }; - - assertThat(listCreatedExtractorClasses).containsNoDuplicates(); - assertThat(listCreatedExtractorClasses).containsExactlyElementsIn(expectedExtractorClassses); + return extractorClasses; } } From 7c33e2570afc1c68c6e58e1d332e836f744b6036 Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 3 Jun 2020 13:28:51 +0100 Subject: [PATCH 0411/1052] Update getFormatFromExtension to take a URI This allows to handle the last segment retrieval and process the case where the filename is null in the method. PiperOrigin-RevId: 314512974 --- .../android/exoplayer2/util/FilenameUtil.java | 11 ++++++--- .../exoplayer2/util/FilenameUtilTest.java | 23 +++++++++++++++---- .../extractor/DefaultExtractorsFactory.java | 5 +--- .../hls/DefaultHlsExtractorFactory.java | 6 +---- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java index 234d855c4e..614f58e94b 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.util; +import android.net.Uri; import androidx.annotation.IntDef; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -112,11 +113,15 @@ public final class FilenameUtil { private FilenameUtil() {} /** - * Returns the {@link FileFormat} corresponding to the extension of the provided {@code filename}. + * Returns the {@link FileFormat} corresponding to the filename extension of the provided {@link + * Uri}. The filename is considered to be the last segment of the {@link Uri} path. */ @FileFormat - public static int getFormatFromExtension(String filename) { - if (filename.endsWith(FILE_EXTENSION_AC3) || filename.endsWith(FILE_EXTENSION_EC3)) { + public static int getFormatFromExtension(Uri uri) { + String filename = uri.getLastPathSegment(); + if (filename == null) { + return FILE_FORMAT_UNKNOWN; + } else if (filename.endsWith(FILE_EXTENSION_AC3) || filename.endsWith(FILE_EXTENSION_EC3)) { return FILE_FORMAT_AC3; } else if (filename.endsWith(FILE_EXTENSION_AC4)) { return FILE_FORMAT_AC4; diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java index 89270e0f33..d3849356f3 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java @@ -21,6 +21,7 @@ import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_UNKNOW import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; import static com.google.common.truth.Truth.assertThat; +import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,16 +32,30 @@ public class FilenameUtilTest { @Test public void getFormatFromExtension_withExtension_returnsExpectedFormat() { - assertThat(getFormatFromExtension("filename.mp3")).isEqualTo(FILE_FORMAT_MP3); + assertThat(getFormatFromExtension(Uri.parse("filename.mp3"))).isEqualTo(FILE_FORMAT_MP3); } @Test public void getFormatFromExtension_withExtensionPrefix_returnsExpectedFormat() { - assertThat(getFormatFromExtension("filename.mka")).isEqualTo(FILE_FORMAT_MATROSKA); + assertThat(getFormatFromExtension(Uri.parse("filename.mka"))).isEqualTo(FILE_FORMAT_MATROSKA); } @Test - public void getFormatFromExtension_unknownExtension_returnsUnknownFormat() { - assertThat(getFormatFromExtension("filename.unknown")).isEqualTo(FILE_FORMAT_UNKNOWN); + public void getFormatFromExtension_withUnknownExtension_returnsUnknownFormat() { + assertThat(getFormatFromExtension(Uri.parse("filename.unknown"))) + .isEqualTo(FILE_FORMAT_UNKNOWN); + } + + @Test + public void getFormatFromExtension_withUriNotEndingWithFilename_returnsExpectedFormat() { + assertThat( + getFormatFromExtension( + Uri.parse("http://www.example.com/filename.mp3?query=myquery#fragment"))) + .isEqualTo(FILE_FORMAT_MP3); + } + + @Test + public void getFormatFromExtension_withNullFilename_returnsUnknownFormat() { + assertThat(getFormatFromExtension(Uri.EMPTY)).isEqualTo(FILE_FORMAT_UNKNOWN); } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 6f620c319e..431b96a5ea 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -27,7 +27,6 @@ import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP4; import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_OGG; import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_PS; import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_TS; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_UNKNOWN; import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_WAV; import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; @@ -286,9 +285,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { public synchronized Extractor[] createExtractors(Uri uri) { List extractors = new ArrayList<>(/* initialCapacity= */ 14); - String filename = uri.getLastPathSegment(); - @FilenameUtil.FileFormat - int extensionFormat = filename == null ? FILE_FORMAT_UNKNOWN : getFormatFromExtension(filename); + @FilenameUtil.FileFormat int extensionFormat = getFormatFromExtension(uri); addExtractorsForFormat(extensionFormat, extractors); for (int format : DEFAULT_EXTRACTOR_ORDER) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 5df0f88692..010b6e2417 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -195,11 +195,7 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType)) { return new WebvttExtractor(format.language, timestampAdjuster); } - String filename = uri.getLastPathSegment(); - if (filename == null) { - return null; - } - @FilenameUtil.FileFormat int fileFormat = getFormatFromExtension(filename); + @FilenameUtil.FileFormat int fileFormat = getFormatFromExtension(uri); switch (fileFormat) { case FILE_FORMAT_WEBVTT: return new WebvttExtractor(format.language, timestampAdjuster); From fb73a9dfc8c60177e360c31b0a77e697d5b9bb53 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 3 Jun 2020 15:37:44 +0100 Subject: [PATCH 0412/1052] Make media item of Timeline.Window non-null This change makes the media item of Timeline.Window non-null by providing a fallback media item in window.set(...). After having migrated all media sources this should not be needed anymore, but a fallback makes it more safe than just making the mediaItem argument of window.set(...) non-null (which is done in a following CL in this chain of CLs). PiperOrigin-RevId: 314527983 --- .../google/android/exoplayer2/Timeline.java | 24 ++++++++++++------- .../android/exoplayer2/TimelineTest.java | 16 ++++++++----- .../source/SinglePeriodTimelineTest.java | 5 ++-- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index c3d9cab7ab..7e22671f00 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2; +import android.net.Uri; import android.os.SystemClock; import android.util.Pair; import androidx.annotation.Nullable; @@ -123,6 +124,12 @@ public abstract class Timeline { */ public static final Object SINGLE_WINDOW_UID = new Object(); + private static final MediaItem DUMMY_MEDIA_ITEM = + new MediaItem.Builder() + .setMediaId("com.google.android.exoplayer2.Timeline") + .setUri(Uri.EMPTY) + .build(); + /** * A unique identifier for the window. Single-window {@link Timeline Timelines} must use {@link * #SINGLE_WINDOW_UID}. @@ -133,7 +140,7 @@ public abstract class Timeline { @Deprecated @Nullable public Object tag; /** The {@link MediaItem} associated to the window. Not necessarily unique. */ - @Nullable public MediaItem mediaItem; + public MediaItem mediaItem; /** The manifest of the window. May be {@code null}. */ @Nullable public Object manifest; @@ -215,13 +222,13 @@ public abstract class Timeline { /** Creates window. */ public Window() { uid = SINGLE_WINDOW_UID; + mediaItem = DUMMY_MEDIA_ITEM; } /** * @deprecated Use {@link #set(Object, MediaItem, Object, long, long, long, boolean, boolean, * boolean, long, long, int, int, long)} instead. */ - @SuppressWarnings("deprecation") @Deprecated public Window set( Object uid, @@ -240,7 +247,7 @@ public abstract class Timeline { long positionInFirstPeriodUs) { set( uid, - /* mediaItem= */ null, + DUMMY_MEDIA_ITEM.buildUpon().setTag(tag).build(), manifest, presentationStartTimeMs, windowStartTimeMs, @@ -253,7 +260,6 @@ public abstract class Timeline { firstPeriodIndex, lastPeriodIndex, positionInFirstPeriodUs); - this.tag = tag; return this; } @@ -275,7 +281,7 @@ public abstract class Timeline { int lastPeriodIndex, long positionInFirstPeriodUs) { this.uid = uid; - this.mediaItem = mediaItem; + this.mediaItem = mediaItem != null ? mediaItem : DUMMY_MEDIA_ITEM; this.tag = mediaItem != null && mediaItem.playbackProperties != null ? mediaItem.playbackProperties.tag @@ -356,6 +362,7 @@ public abstract class Timeline { return Util.getNowUnixTimeMs(elapsedRealtimeEpochOffsetMs); } + // Provide backward compatibility for tag. @Override public boolean equals(@Nullable Object obj) { if (this == obj) { @@ -366,7 +373,6 @@ public abstract class Timeline { } Window that = (Window) obj; return Util.areEqual(uid, that.uid) - && Util.areEqual(tag, that.tag) && Util.areEqual(mediaItem, that.mediaItem) && Util.areEqual(manifest, that.manifest) && presentationStartTimeMs == that.presentationStartTimeMs @@ -383,12 +389,12 @@ public abstract class Timeline { && positionInFirstPeriodUs == that.positionInFirstPeriodUs; } + // Provide backward compatibility for tag. @Override public int hashCode() { int result = 7; result = 31 * result + uid.hashCode(); - result = 31 * result + (tag == null ? 0 : tag.hashCode()); - result = 31 * result + (mediaItem == null ? 0 : mediaItem.hashCode()); + result = 31 * result + mediaItem.hashCode(); result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); @@ -688,7 +694,7 @@ public abstract class Timeline { result = 31 * result + windowIndex; result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); - result = 31 * result + (adPlaybackState == null ? 0 : adPlaybackState.hashCode()); + result = 31 * result + adPlaybackState.hashCode(); return result; } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java index a151507db4..06fb452444 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2; import static com.google.common.truth.Truth.assertThat; +import android.net.Uri; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.FakeTimeline; @@ -62,7 +63,6 @@ public class TimelineTest { TimelineAsserts.assertNextWindowIndices(timeline, Player.REPEAT_MODE_ALL, false, 0); } - @SuppressWarnings("deprecation") // Tests the deprecated window.tag property. @Test public void windowEquals() { MediaItem mediaItem = new MediaItem.Builder().setUri("uri").setTag(new Object()).build(); @@ -73,10 +73,6 @@ public class TimelineTest { otherWindow.mediaItem = mediaItem; assertThat(window).isNotEqualTo(otherWindow); - otherWindow = new Timeline.Window(); - otherWindow.tag = mediaItem.playbackProperties.tag; - assertThat(window).isNotEqualTo(otherWindow); - otherWindow = new Timeline.Window(); otherWindow.manifest = new Object(); assertThat(window).isNotEqualTo(otherWindow); @@ -148,7 +144,15 @@ public class TimelineTest { @SuppressWarnings("deprecation") @Test public void windowSet_withTag() { - Timeline.Window window = populateWindow(/* mediaItem= */ null, new Object()); + Object tag = new Object(); + Timeline.Window window = + populateWindow( + new MediaItem.Builder() + .setMediaId("com.google.android.exoplayer2.Timeline") + .setUri(Uri.EMPTY) + .setTag(tag) + .build(), + tag); Timeline.Window otherWindow = new Timeline.Window(); otherWindow = otherWindow.set( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java index bc3713379c..ea0b9f1538 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java @@ -108,7 +108,7 @@ public final class SinglePeriodTimelineTest { } @Test - public void setNullMediaItem_returnsNullMediaItem_butUsesDefaultUid() { + public void setNullMediaItem_returnsFallbackMediaItem_butUsesDefaultUid() { SinglePeriodTimeline timeline = new SinglePeriodTimeline( /* durationUs= */ C.TIME_UNSET, @@ -118,7 +118,8 @@ public final class SinglePeriodTimelineTest { /* manifest= */ null, /* mediaItem= */ null); - assertThat(timeline.getWindow(/* windowIndex= */ 0, window).mediaItem).isNull(); + assertThat(timeline.getWindow(/* windowIndex= */ 0, window).mediaItem.mediaId) + .isEqualTo("com.google.android.exoplayer2.Timeline"); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).id).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).id).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).uid).isNull(); From c77e300249d5857c0c8c0920b8b41bae4099b529 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 4 Jun 2020 12:52:43 +0100 Subject: [PATCH 0413/1052] Clean up debug logging PiperOrigin-RevId: 314707946 --- .../jobdispatcher/JobDispatcherScheduler.java | 26 +++++-------------- .../ext/workmanager/WorkManagerScheduler.java | 26 +++++-------------- .../scheduler/PlatformScheduler.java | 26 +++++-------------- 3 files changed, 19 insertions(+), 59 deletions(-) diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java index f301f3e39d..b65988a5e2 100644 --- a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java +++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java @@ -60,7 +60,6 @@ import com.google.android.exoplayer2.util.Util; @Deprecated public final class JobDispatcherScheduler implements Scheduler { - private static final boolean DEBUG = false; private static final String TAG = "JobDispatcherScheduler"; private static final String KEY_SERVICE_ACTION = "service_action"; private static final String KEY_SERVICE_PACKAGE = "service_package"; @@ -90,14 +89,12 @@ public final class JobDispatcherScheduler implements Scheduler { public boolean schedule(Requirements requirements, String servicePackage, String serviceAction) { Job job = buildJob(jobDispatcher, requirements, jobTag, servicePackage, serviceAction); int result = jobDispatcher.schedule(job); - logd("Scheduling job: " + jobTag + " result: " + result); return result == FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS; } @Override public boolean cancel() { int result = jobDispatcher.cancel(jobTag); - logd("Canceling job: " + jobTag + " result: " + result); return result == FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS; } @@ -147,31 +144,20 @@ public final class JobDispatcherScheduler implements Scheduler { return builder.build(); } - private static void logd(String message) { - if (DEBUG) { - Log.d(TAG, message); - } - } - /** A {@link JobService} that starts the target service if the requirements are met. */ public static final class JobDispatcherSchedulerService extends JobService { @Override public boolean onStartJob(JobParameters params) { - logd("JobDispatcherSchedulerService is started"); - Bundle extras = params.getExtras(); - Assertions.checkNotNull(extras, "Service started without extras."); + Bundle extras = Assertions.checkNotNull(params.getExtras()); Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS)); - if (requirements.checkRequirements(this)) { - logd("Requirements are met"); - String serviceAction = extras.getString(KEY_SERVICE_ACTION); - String servicePackage = extras.getString(KEY_SERVICE_PACKAGE); - Assertions.checkNotNull(serviceAction, "Service action missing."); - Assertions.checkNotNull(servicePackage, "Service package missing."); + int notMetRequirements = requirements.getNotMetRequirements(this); + if (notMetRequirements == 0) { + String serviceAction = Assertions.checkNotNull(extras.getString(KEY_SERVICE_ACTION)); + String servicePackage = Assertions.checkNotNull(extras.getString(KEY_SERVICE_PACKAGE)); Intent intent = new Intent(serviceAction).setPackage(servicePackage); - logd("Starting service action: " + serviceAction + " package: " + servicePackage); Util.startForegroundService(this, intent); } else { - logd("Requirements are not met"); + Log.w(TAG, "Requirements not met: " + notMetRequirements); jobFinished(params, /* needsReschedule */ true); } return false; diff --git a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java index e88b47c575..6ecace6fa5 100644 --- a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java +++ b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java @@ -35,7 +35,6 @@ import com.google.android.exoplayer2.util.Util; /** A {@link Scheduler} that uses {@link WorkManager}. */ public final class WorkManagerScheduler implements Scheduler { - private static final boolean DEBUG = false; private static final String TAG = "WorkManagerScheduler"; private static final String KEY_SERVICE_ACTION = "service_action"; private static final String KEY_SERVICE_PACKAGE = "service_package"; @@ -64,14 +63,12 @@ public final class WorkManagerScheduler implements Scheduler { Constraints constraints = buildConstraints(requirements); Data inputData = buildInputData(requirements, servicePackage, serviceAction); OneTimeWorkRequest workRequest = buildWorkRequest(constraints, inputData); - logd("Scheduling work: " + workName); WorkManager.getInstance().enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest); return true; } @Override public boolean cancel() { - logd("Canceling work: " + workName); WorkManager.getInstance().cancelUniqueWork(workName); return true; } @@ -136,12 +133,6 @@ public final class WorkManagerScheduler implements Scheduler { return builder.build(); } - private static void logd(String message) { - if (DEBUG) { - Log.d(TAG, message); - } - } - /** A {@link Worker} that starts the target service if the requirements are met. */ // This class needs to be public so that WorkManager can instantiate it. public static final class SchedulerWorker extends Worker { @@ -157,22 +148,17 @@ public final class WorkManagerScheduler implements Scheduler { @Override public Result doWork() { - logd("SchedulerWorker is started"); - Data inputData = workerParams.getInputData(); - Assertions.checkNotNull(inputData, "Work started without input data."); + Data inputData = Assertions.checkNotNull(workerParams.getInputData()); Requirements requirements = new Requirements(inputData.getInt(KEY_REQUIREMENTS, 0)); - if (requirements.checkRequirements(context)) { - logd("Requirements are met"); - String serviceAction = inputData.getString(KEY_SERVICE_ACTION); - String servicePackage = inputData.getString(KEY_SERVICE_PACKAGE); - Assertions.checkNotNull(serviceAction, "Service action missing."); - Assertions.checkNotNull(servicePackage, "Service package missing."); + int notMetRequirements = requirements.getNotMetRequirements(context); + if (notMetRequirements == 0) { + String serviceAction = Assertions.checkNotNull(inputData.getString(KEY_SERVICE_ACTION)); + String servicePackage = Assertions.checkNotNull(inputData.getString(KEY_SERVICE_PACKAGE)); Intent intent = new Intent(serviceAction).setPackage(servicePackage); - logd("Starting service action: " + serviceAction + " package: " + servicePackage); Util.startForegroundService(context, intent); return Result.success(); } else { - logd("Requirements are not met"); + Log.w(TAG, "Requirements not met: " + notMetRequirements); return Result.retry(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java index c8b6438fd8..11036fc77c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java @@ -45,7 +45,6 @@ import com.google.android.exoplayer2.util.Util; @RequiresApi(21) public final class PlatformScheduler implements Scheduler { - private static final boolean DEBUG = false; private static final String TAG = "PlatformScheduler"; private static final String KEY_SERVICE_ACTION = "service_action"; private static final String KEY_SERVICE_PACKAGE = "service_package"; @@ -81,13 +80,11 @@ public final class PlatformScheduler implements Scheduler { JobInfo jobInfo = buildJobInfo(jobId, jobServiceComponentName, requirements, serviceAction, servicePackage); int result = jobScheduler.schedule(jobInfo); - logd("Scheduling job: " + jobId + " result: " + result); return result == JobScheduler.RESULT_SUCCESS; } @Override public boolean cancel() { - logd("Canceling job: " + jobId); jobScheduler.cancel(jobId); return true; } @@ -135,30 +132,21 @@ public final class PlatformScheduler implements Scheduler { return builder.build(); } - private static void logd(String message) { - if (DEBUG) { - Log.d(TAG, message); - } - } - /** A {@link JobService} that starts the target service if the requirements are met. */ public static final class PlatformSchedulerService extends JobService { @Override public boolean onStartJob(JobParameters params) { - logd("PlatformSchedulerService started"); PersistableBundle extras = params.getExtras(); Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS)); - if (requirements.checkRequirements(this)) { - logd("Requirements are met"); - String serviceAction = extras.getString(KEY_SERVICE_ACTION); - String servicePackage = extras.getString(KEY_SERVICE_PACKAGE); - Intent intent = - new Intent(Assertions.checkNotNull(serviceAction)).setPackage(servicePackage); - logd("Starting service action: " + serviceAction + " package: " + servicePackage); + int notMetRequirements = requirements.getNotMetRequirements(this); + if (notMetRequirements == 0) { + String serviceAction = Assertions.checkNotNull(extras.getString(KEY_SERVICE_ACTION)); + String servicePackage = Assertions.checkNotNull(extras.getString(KEY_SERVICE_PACKAGE)); + Intent intent = new Intent(serviceAction).setPackage(servicePackage); Util.startForegroundService(this, intent); } else { - logd("Requirements are not met"); - jobFinished(params, /* needsReschedule */ true); + Log.w(TAG, "Requirements not met: " + notMetRequirements); + jobFinished(params, /* wantsReschedule= */ true); } return false; } From 9b5cab04781c0d60f5786cb973e1e7440c70f472 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 4 Jun 2020 13:15:51 +0100 Subject: [PATCH 0414/1052] Fix more cases of downloads not being resumed Issue: #7453 PiperOrigin-RevId: 314710328 --- RELEASENOTES.md | 5 +- .../exoplayer2/scheduler/Requirements.java | 25 +++++----- .../scheduler/RequirementsWatcher.java | 48 ++++++++++++++++--- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0f9a4b855d..8153b58f60 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -186,7 +186,7 @@ * Demo app: Retain previous position in list of samples. * Add Guava dependency. -### 2.11.5 (2020-06-03) ### +### 2.11.5 (2020-06-04) ### * Improve the smoothness of video playback immediately after starting, seeking or resuming a playback @@ -203,6 +203,9 @@ * Fix issue in `AudioTrackPositionTracker` that could cause negative positions to be reported at the start of playback and immediately after seeking ([#7456](https://github.com/google/ExoPlayer/issues/7456). +* Fix further cases where downloads would sometimes not resume after their + network requirements are met + ([#7453](https://github.com/google/ExoPlayer/issues/7453). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 334c1684bd..8cee69ebcc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -154,11 +154,9 @@ public final class Requirements implements Parcelable { } ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo networkInfo = Assertions.checkNotNull(connectivityManager).getActiveNetworkInfo(); - if (networkInfo == null - || !networkInfo.isConnected() - || !isInternetConnectivityValidated(connectivityManager)) { + (ConnectivityManager) + Assertions.checkNotNull(context.getSystemService(Context.CONNECTIVITY_SERVICE)); + if (!isInternetConnectivityValidated(connectivityManager)) { return requirements & (NETWORK | NETWORK_UNMETERED); } @@ -183,7 +181,8 @@ public final class Requirements implements Parcelable { } private boolean isDeviceIdle(Context context) { - PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + PowerManager powerManager = + (PowerManager) Assertions.checkNotNull(context.getSystemService(Context.POWER_SERVICE)); return Util.SDK_INT >= 23 ? powerManager.isDeviceIdleMode() : Util.SDK_INT >= 20 ? !powerManager.isInteractive() : !powerManager.isScreenOn(); @@ -196,16 +195,20 @@ public final class Requirements implements Parcelable { } private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { - // It's possible to query NetworkCapabilities from API level 23, but RequirementsWatcher only - // fires an event to update its Requirements when NetworkCapabilities change from API level 24. - // Since Requirements won't be updated, we assume connectivity is validated on API level 23. + // It's possible to check NetworkCapabilities.NET_CAPABILITY_VALIDATED from API level 23, but + // RequirementsWatcher only fires an event to re-check the requirements when NetworkCapabilities + // change from API level 24. We use the legacy path for API level 23 here to keep in sync. if (Util.SDK_INT < 24) { - return true; + // Legacy path. + @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); } - Network activeNetwork = connectivityManager.getActiveNetwork(); + + @Nullable Network activeNetwork = connectivityManager.getActiveNetwork(); if (activeNetwork == null) { return false; } + @Nullable NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork); return networkCapabilities != null diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index 9109242db1..849511ef3f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -153,6 +153,23 @@ public final class RequirementsWatcher { } } + /** + * Re-checks the requirements if there are network requirements that are currently not met. + * + *

      When we receive an event that implies newly established network connectivity, we re-check + * the requirements by calling {@link #checkRequirements()}. This check sometimes sees that there + * is still no active network, meaning that any network requirements will remain not met. By + * calling this method when we receive other events that imply continued network connectivity, we + * can detect that the requirements are met once an active network does exist. + */ + private void recheckNotMetNetworkRequirements() { + if ((notMetRequirements & (Requirements.NETWORK | Requirements.NETWORK_UNMETERED)) == 0) { + // No unmet network requirements to recheck. + return; + } + checkRequirements(); + } + private class DeviceStatusChangeReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { @@ -164,17 +181,25 @@ public final class RequirementsWatcher { @RequiresApi(24) private final class NetworkCallback extends ConnectivityManager.NetworkCallback { - boolean receivedCapabilitiesChange; - boolean networkValidated; + + private boolean receivedCapabilitiesChange; + private boolean networkValidated; @Override public void onAvailable(Network network) { - onNetworkCallback(); + postCheckRequirements(); } @Override public void onLost(Network network) { - onNetworkCallback(); + postCheckRequirements(); + } + + @Override + public void onBlockedStatusChanged(Network network, boolean blocked) { + if (!blocked) { + postRecheckNotMetNetworkRequirements(); + } } @Override @@ -184,11 +209,13 @@ public final class RequirementsWatcher { if (!receivedCapabilitiesChange || this.networkValidated != networkValidated) { receivedCapabilitiesChange = true; this.networkValidated = networkValidated; - onNetworkCallback(); + postCheckRequirements(); + } else if (networkValidated) { + postRecheckNotMetNetworkRequirements(); } } - private void onNetworkCallback() { + private void postCheckRequirements() { handler.post( () -> { if (networkCallback != null) { @@ -196,5 +223,14 @@ public final class RequirementsWatcher { } }); } + + private void postRecheckNotMetNetworkRequirements() { + handler.post( + () -> { + if (networkCallback != null) { + recheckNotMetNetworkRequirements(); + } + }); + } } } From 3474c39c10f3b02bc3d66f15628b9d7ee177cd9a Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 4 Jun 2020 14:09:57 +0100 Subject: [PATCH 0415/1052] Add support for non-contiguous Ogg pages bear_vorbis_gap.ogg is a copy of bear_vorbis.ogg with 10 garbage bytes (DE AD BE EF DE AD BE EF DE AD) inserted before the second capture pattern and 3 garbage bytes inserted at the end (DE AD BE). Issue: #7230 PiperOrigin-RevId: 314715729 --- RELEASENOTES.md | 2 + .../extractor/ogg/DefaultOggSeeker.java | 75 +- .../exoplayer2/extractor/ogg/OggPacket.java | 2 +- .../extractor/ogg/OggPageHeader.java | 96 ++- .../extractor/ogg/DefaultOggSeekerTest.java | 68 -- .../ogg/OggExtractorParameterizedTest.java | 7 + .../extractor/ogg/OggPageHeaderTest.java | 99 ++- .../src/test/assets/ogg/bear_vorbis_gap.ogg | Bin 0 -> 30530 bytes .../assets/ogg/bear_vorbis_gap.ogg.0.dump | 740 ++++++++++++++++++ .../assets/ogg/bear_vorbis_gap.ogg.1.dump | 456 +++++++++++ .../assets/ogg/bear_vorbis_gap.ogg.2.dump | 216 +++++ .../assets/ogg/bear_vorbis_gap.ogg.3.dump | 20 + .../bear_vorbis_gap.ogg.unknown_length.dump | 737 +++++++++++++++++ 13 files changed, 2359 insertions(+), 159 deletions(-) create mode 100644 testdata/src/test/assets/ogg/bear_vorbis_gap.ogg create mode 100644 testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.0.dump create mode 100644 testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.1.dump create mode 100644 testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.2.dump create mode 100644 testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.3.dump create mode 100644 testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.unknown_length.dump diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8153b58f60..5017ab3ec1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -156,6 +156,8 @@ * HLS: * Add support for upstream discard including cancelation of ongoing load ([#6322](https://github.com/google/ExoPlayer/issues/6322)). +* Ogg: Allow non-contiguous pages + ([#7230](https://github.com/google/ExoPlayer/issues/7230)). * Extractors: * Add `IndexSeeker` for accurate seeks in VBR MP3 streams ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java index b7d86632fb..b7a8039489 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java @@ -155,7 +155,7 @@ import java.io.IOException; } long currentPosition = input.getPosition(); - if (!skipToNextPage(input, end)) { + if (!pageHeader.skipToNextPage(input, end)) { if (start == currentPosition) { throw new IOException("No ogg page can be found."); } @@ -200,68 +200,21 @@ import java.io.IOException; * @throws IOException If reading from the input fails. */ private void skipToPageOfTargetGranule(ExtractorInput input) throws IOException { - pageHeader.populate(input, /* quiet= */ false); - while (pageHeader.granulePosition <= targetGranule) { + while (true) { + // If pageHeader.skipToNextPage fails to find a page it will advance input.position to the + // end of the file, so pageHeader.populate will throw EOFException (because quiet=false). + pageHeader.skipToNextPage(input); + pageHeader.populate(input, /* quiet= */ false); + if (pageHeader.granulePosition > targetGranule) { + break; + } input.skipFully(pageHeader.headerSize + pageHeader.bodySize); start = input.getPosition(); startGranule = pageHeader.granulePosition; - pageHeader.populate(input, /* quiet= */ false); } input.resetPeekPosition(); } - /** - * Skips to the next page. - * - * @param input The {@code ExtractorInput} to skip to the next page. - * @throws IOException If peeking/reading from the input fails. - * @throws EOFException If the next page can't be found before the end of the input. - */ - @VisibleForTesting - void skipToNextPage(ExtractorInput input) throws IOException { - if (!skipToNextPage(input, payloadEndPosition)) { - // Not found until eof. - throw new EOFException(); - } - } - - /** - * Skips to the next page. Searches for the next page header. - * - * @param input The {@code ExtractorInput} to skip to the next page. - * @param limit The limit up to which the search should take place. - * @return Whether the next page was found. - * @throws IOException If peeking/reading from the input fails. - */ - private boolean skipToNextPage(ExtractorInput input, long limit) throws IOException { - limit = Math.min(limit + 3, payloadEndPosition); - byte[] buffer = new byte[2048]; - int peekLength = buffer.length; - while (true) { - if (input.getPosition() + peekLength > limit) { - // Make sure to not peek beyond the end of the input. - peekLength = (int) (limit - input.getPosition()); - if (peekLength < 4) { - // Not found until end. - return false; - } - } - input.peekFully(buffer, 0, peekLength, false); - for (int i = 0; i < peekLength - 3; i++) { - if (buffer[i] == 'O' - && buffer[i + 1] == 'g' - && buffer[i + 2] == 'g' - && buffer[i + 3] == 'S') { - // Match! Skip to the start of the pattern. - input.skipFully(i); - return true; - } - } - // Overlap by not skipping the entire peekLength. - input.skipFully(peekLength - 3); - } - } - /** * Skips to the last Ogg page in the stream and reads the header's granule field which is the * total number of samples per channel. @@ -272,12 +225,16 @@ import java.io.IOException; */ @VisibleForTesting long readGranuleOfLastPage(ExtractorInput input) throws IOException { - skipToNextPage(input); pageHeader.reset(); - while ((pageHeader.type & 0x04) != 0x04 && input.getPosition() < payloadEndPosition) { + if (!pageHeader.skipToNextPage(input)) { + throw new EOFException(); + } + do { pageHeader.populate(input, /* quiet= */ false); input.skipFully(pageHeader.headerSize + pageHeader.bodySize); - } + } while ((pageHeader.type & 0x04) != 0x04 + && pageHeader.skipToNextPage(input) + && input.getPosition() < payloadEndPosition); return pageHeader.granulePosition; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java index 2ee65f0112..07724cc33c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java @@ -67,7 +67,7 @@ import java.util.Arrays; while (!populated) { if (currentSegmentIndex < 0) { // We're at the start of a page. - if (!pageHeader.populate(input, true)) { + if (!pageHeader.skipToNextPage(input) || !pageHeader.populate(input, /* quiet= */ true)) { return false; } int segmentIndex = 0; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java index d96aaa4568..853347ee18 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.extractor.ogg; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; @@ -33,7 +34,8 @@ import java.io.IOException; public static final int MAX_PAGE_SIZE = EMPTY_PAGE_HEADER_SIZE + MAX_SEGMENT_COUNT + MAX_PAGE_PAYLOAD; - private static final int TYPE_OGGS = 0x4f676753; + private static final int CAPTURE_PATTERN = 0x4f676753; // OggS + private static final int CAPTURE_PATTERN_SIZE = 4; public int revision; public int type; @@ -73,6 +75,51 @@ import java.io.IOException; bodySize = 0; } + /** + * Advances through {@code input} looking for the start of the next Ogg page. + * + *

      Equivalent to {@link #skipToNextPage(ExtractorInput, long) skipToNextPage(input, /* limit= + * *\/ C.POSITION_UNSET)}. + */ + public boolean skipToNextPage(ExtractorInput input) throws IOException { + return skipToNextPage(input, /* limit= */ C.POSITION_UNSET); + } + + /** + * Advances through {@code input} looking for the start of the next Ogg page. + * + *

      The start of a page is identified by the 4-byte capture_pattern 'OggS'. + * + *

      Returns {@code true} if a capture pattern was found, with the read and peek positions of + * {@code input} at the start of the page, just before the capture_pattern. Otherwise returns + * {@code false}, with the read and peek positions of {@code input} at either {@code limit} (if + * set) or end-of-input. + * + * @param input The {@link ExtractorInput} to read from (must have {@code readPosition == + * peekPosition}). + * @param limit The max position in {@code input} to peek to, or {@link C#POSITION_UNSET} to allow + * peeking to the end. + * @return True if a capture_pattern was found. + * @throws IOException If reading data fails. + */ + public boolean skipToNextPage(ExtractorInput input, long limit) throws IOException { + Assertions.checkArgument(input.getPosition() == input.getPeekPosition()); + while ((limit == C.POSITION_UNSET || input.getPosition() + CAPTURE_PATTERN_SIZE < limit) + && peekSafely(input, scratch.data, 0, CAPTURE_PATTERN_SIZE, /* quiet= */ true)) { + scratch.reset(); + if (scratch.readUnsignedInt() == CAPTURE_PATTERN) { + input.resetPeekPosition(); + return true; + } + // Advance one byte before looking for the capture pattern again. + input.skipFully(1); + } + // Move the read & peek positions to limit or end-of-input, whichever is closer. + while ((limit == C.POSITION_UNSET || input.getPosition() < limit) + && input.skip(1) != C.RESULT_END_OF_INPUT) {} + return false; + } + /** * Peeks an Ogg page header and updates this {@link OggPageHeader}. * @@ -84,23 +131,11 @@ import java.io.IOException; * @throws IOException If reading data fails or the stream is invalid. */ public boolean populate(ExtractorInput input, boolean quiet) throws IOException { - scratch.reset(); reset(); - boolean hasEnoughBytes = input.getLength() == C.LENGTH_UNSET - || input.getLength() - input.getPeekPosition() >= EMPTY_PAGE_HEADER_SIZE; - if (!hasEnoughBytes || !input.peekFully(scratch.data, 0, EMPTY_PAGE_HEADER_SIZE, true)) { - if (quiet) { - return false; - } else { - throw new EOFException(); - } - } - if (scratch.readUnsignedInt() != TYPE_OGGS) { - if (quiet) { - return false; - } else { - throw new ParserException("expected OggS capture pattern at begin of page"); - } + scratch.reset(); + if (!peekSafely(input, scratch.data, 0, EMPTY_PAGE_HEADER_SIZE, quiet) + || scratch.readUnsignedInt() != CAPTURE_PATTERN) { + return false; } revision = scratch.readUnsignedByte(); @@ -130,4 +165,31 @@ import java.io.IOException; return true; } + + /** + * Peek data from {@code input}, respecting {@code quiet}. Return true if the peek is successful. + * + *

      If {@code quiet=false} then encountering the end of the input (whether before or after + * reading some data) will throw {@link EOFException}. + * + *

      If {@code quiet=true} then encountering the end of the input (even after reading some data) + * will return {@code false}. + * + *

      This is slightly different to the behaviour of {@link ExtractorInput#peekFully(byte[], int, + * int, boolean)}, where {@code allowEndOfInput=true} only returns false (and suppresses the + * exception) if the end of the input is reached before reading any data. + */ + private static boolean peekSafely( + ExtractorInput input, byte[] output, int offset, int length, boolean quiet) + throws IOException { + try { + return input.peekFully(output, offset, length, /* allowEndOfInput= */ quiet); + } catch (EOFException e) { + if (quiet) { + return false; + } else { + throw e; + } + } + } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java index be471ac40c..57b9525d10 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java @@ -22,11 +22,9 @@ import static org.junit.Assert.fail; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; -import com.google.common.primitives.Bytes; import java.io.EOFException; import java.io.IOException; import java.util.Random; @@ -122,53 +120,6 @@ public final class DefaultOggSeekerTest { } } - @Test - public void skipToNextPage_success() throws Exception { - FakeExtractorInput extractorInput = - createInput( - Bytes.concat( - TestUtil.buildTestData(4000, random), - new byte[] {'O', 'g', 'g', 'S'}, - TestUtil.buildTestData(4000, random)), - /* simulateUnknownLength= */ false); - skipToNextPage(extractorInput); - assertThat(extractorInput.getPosition()).isEqualTo(4000); - } - - @Test - public void skipToNextPage_withOverlappingInput_success() throws Exception { - FakeExtractorInput extractorInput = - createInput( - Bytes.concat( - TestUtil.buildTestData(2046, random), - new byte[] {'O', 'g', 'g', 'S'}, - TestUtil.buildTestData(4000, random)), - /* simulateUnknownLength= */ false); - skipToNextPage(extractorInput); - assertThat(extractorInput.getPosition()).isEqualTo(2046); - } - - @Test - public void skipToNextPage_withInputShorterThanPeekLength_success() throws Exception { - FakeExtractorInput extractorInput = - createInput( - Bytes.concat(new byte[] {'x', 'O', 'g', 'g', 'S'}), /* simulateUnknownLength= */ false); - skipToNextPage(extractorInput); - assertThat(extractorInput.getPosition()).isEqualTo(1); - } - - @Test - public void skipToNextPage_withoutMatch_throwsException() throws Exception { - FakeExtractorInput extractorInput = - createInput(new byte[] {'g', 'g', 'S', 'O', 'g', 'g'}, /* simulateUnknownLength= */ false); - try { - skipToNextPage(extractorInput); - fail(); - } catch (EOFException e) { - // expected - } - } - @Test public void readGranuleOfLastPage() throws IOException { // This test stream has three headers with granule numbers 20000, 40000 and 60000. @@ -200,25 +151,6 @@ public final class DefaultOggSeekerTest { } } - private static void skipToNextPage(ExtractorInput extractorInput) throws IOException { - DefaultOggSeeker oggSeeker = - new DefaultOggSeeker( - /* streamReader= */ new FlacReader(), - /* payloadStartPosition= */ 0, - /* payloadEndPosition= */ extractorInput.getLength(), - /* firstPayloadPageSize= */ 1, - /* firstPayloadPageGranulePosition= */ 2, - /* firstPayloadPageIsLastPage= */ false); - while (true) { - try { - oggSeeker.skipToNextPage(extractorInput); - break; - } catch (FakeExtractorInput.SimulatedIOException e) { - /* ignored */ - } - } - } - private static void assertReadGranuleOfLastPage(FakeExtractorInput input, int expected) throws IOException { DefaultOggSeeker oggSeeker = diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java index 9b2c6caf89..fa9879f77a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java @@ -59,4 +59,11 @@ public final class OggExtractorParameterizedTest { public void vorbis() throws Exception { ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_vorbis.ogg", simulationConfig); } + + // Ensure the extractor can handle non-contiguous pages by using a file with 10 bytes of garbage + // data before the start of the second page. + @Test + public void vorbisWithGapBeforeSecondPage() throws Exception { + ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_vorbis_gap.ogg", simulationConfig); + } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java index 6b5ffe8f91..6dde47bed3 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java @@ -23,6 +23,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput.SimulatedIOException; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.common.primitives.Bytes; +import java.io.IOException; +import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,14 +33,69 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class OggPageHeaderTest { + private final Random random; + + public OggPageHeaderTest() { + this.random = new Random(/* seed= */ 0); + } + + @Test + public void skipToNextPage_success() throws Exception { + FakeExtractorInput input = + createInput( + Bytes.concat( + TestUtil.buildTestData(20, random), + new byte[] {'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(20, random)), + /* simulateUnknownLength= */ false); + OggPageHeader oggHeader = new OggPageHeader(); + + boolean result = retrySimulatedIOException(() -> oggHeader.skipToNextPage(input)); + + assertThat(result).isTrue(); + assertThat(input.getPosition()).isEqualTo(20); + } + + @Test + public void skipToNextPage_noPage_returnsFalse() throws Exception { + FakeExtractorInput input = + createInput( + Bytes.concat(TestUtil.buildTestData(20, random)), /* simulateUnknownLength= */ false); + OggPageHeader oggHeader = new OggPageHeader(); + + boolean result = retrySimulatedIOException(() -> oggHeader.skipToNextPage(input)); + + assertThat(result).isFalse(); + assertThat(input.getPosition()).isEqualTo(20); + } + + @Test + public void skipToNextPage_respectsLimit() throws Exception { + FakeExtractorInput input = + createInput( + Bytes.concat( + TestUtil.buildTestData(20, random), + new byte[] {'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(20, random)), + /* simulateUnknownLength= */ false); + OggPageHeader oggHeader = new OggPageHeader(); + + boolean result = retrySimulatedIOException(() -> oggHeader.skipToNextPage(input, 10)); + + assertThat(result).isFalse(); + assertThat(input.getPosition()).isEqualTo(10); + } + @Test public void populatePageHeader_success() throws Exception { byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/page_header"); FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ true); OggPageHeader header = new OggPageHeader(); - populatePageHeader(input, header, /* quiet= */ false); + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ false)); + + assertThat(result).isTrue(); assertThat(header.type).isEqualTo(0x01); assertThat(header.headerSize).isEqualTo(27 + 2); assertThat(header.bodySize).isEqualTo(4); @@ -55,7 +113,10 @@ public final class OggPageHeaderTest { FakeExtractorInput input = createInput(TestUtil.createByteArray(2, 2), /* simulateUnknownLength= */ false); OggPageHeader header = new OggPageHeader(); - assertThat(populatePageHeader(input, header, /* quiet= */ true)).isFalse(); + + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ true)); + + assertThat(result).isFalse(); } @Test @@ -65,7 +126,10 @@ public final class OggPageHeaderTest { data[0] = 'o'; FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ false); OggPageHeader header = new OggPageHeader(); - assertThat(populatePageHeader(input, header, /* quiet= */ true)).isFalse(); + + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ true)); + + assertThat(result).isFalse(); } @Test @@ -75,18 +139,10 @@ public final class OggPageHeaderTest { data[4] = 0x01; FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ false); OggPageHeader header = new OggPageHeader(); - assertThat(populatePageHeader(input, header, /* quiet= */ true)).isFalse(); - } - private static boolean populatePageHeader( - FakeExtractorInput input, OggPageHeader header, boolean quiet) throws Exception { - while (true) { - try { - return header.populate(input, quiet); - } catch (SimulatedIOException e) { - // ignored - } - } + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ true)); + + assertThat(result).isFalse(); } private static FakeExtractorInput createInput(byte[] data, boolean simulateUnknownLength) { @@ -97,5 +153,20 @@ public final class OggPageHeaderTest { .setSimulatePartialReads(true) .build(); } + + private static T retrySimulatedIOException(ThrowingSupplier supplier) + throws IOException { + while (true) { + try { + return supplier.get(); + } catch (SimulatedIOException e) { + // ignored + } + } + } + + private interface ThrowingSupplier { + S get() throws E; + } } diff --git a/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg new file mode 100644 index 0000000000000000000000000000000000000000..99d3efac4484ccd10652dfd0ffcc8511a0249f90 GIT binary patch literal 30530 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%0Dl}yf07Y3r9gJ8E2HvS?i#T$_@!Z}X6T2IL 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 R`yM(upUt&<90WAL{XcI9qy7K@ literal 0 HcmV?d00001 diff --git a/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.0.dump b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.0.dump new file mode 100644 index 0000000000..bf05a1a039 --- /dev/null +++ b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.0.dump @@ -0,0 +1,740 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=4005]] + getPosition(1) = [[timeUs=1, position=4005]] + getPosition(1370500) = [[timeUs=1370500, position=4005]] + getPosition(2741000) = [[timeUs=2741000, position=4005]] +numberOfTracks = 1 +track 0: + total output bytes = 26873 + sample count = 180 + format 0: + averageBitrate = 112000 + sampleMimeType = audio/vorbis + channelCount = 2 + sampleRate = 48000 + initializationData: + data = length 30, hash 9A8FF207 + data = length 3832, hash 8A406249 + sample 0: + time = 0 + flags = 1 + data = length 49, hash 2FFF94F0 + sample 1: + time = 0 + flags = 1 + data = length 44, hash 3946418A + sample 2: + time = 2666 + flags = 1 + data = length 55, hash 2A0B878E + sample 3: + time = 5333 + flags = 1 + data = length 53, hash CC3B6879 + sample 4: + time = 8000 + flags = 1 + data = length 215, hash 106AE950 + sample 5: + time = 20000 + flags = 1 + data = length 192, hash 2B219F53 + sample 6: + time = 41333 + flags = 1 + data = length 197, hash FBC39422 + sample 7: + time = 62666 + flags = 1 + data = length 209, hash 386E8979 + sample 8: + time = 84000 + flags = 1 + data = length 42, hash E81162C1 + sample 9: + time = 96000 + flags = 1 + data = length 41, hash F15BEE36 + sample 10: + time = 98666 + flags = 1 + data = length 42, hash D67EB19 + sample 11: + time = 101333 + flags = 1 + data = length 42, hash F4DE4792 + sample 12: + time = 104000 + flags = 1 + data = length 53, hash 80F66AC3 + sample 13: + time = 106666 + flags = 1 + data = length 56, hash DCB9DFC4 + sample 14: + time = 109333 + flags = 1 + data = length 55, hash 4E0C4E9D + sample 15: + time = 112000 + flags = 1 + data = length 203, hash 176B6862 + sample 16: + time = 124000 + flags = 1 + data = length 193, hash AB13CB10 + sample 17: + time = 145333 + flags = 1 + data = length 203, hash DE63DE9F + sample 18: + time = 166666 + flags = 1 + data = length 194, hash 4A9508A2 + sample 19: + time = 188000 + flags = 1 + data = length 210, hash 196899B3 + sample 20: + time = 209333 + flags = 1 + data = length 195, hash B68407F1 + sample 21: + time = 230666 + flags = 1 + data = length 193, hash A1FA86E3 + sample 22: + time = 252000 + flags = 1 + data = length 194, hash 5C0B9343 + sample 23: + time = 273333 + flags = 1 + data = length 198, hash 789914B2 + sample 24: + time = 294666 + flags = 1 + data = length 183, hash 1B82D11F + sample 25: + time = 316000 + flags = 1 + data = length 199, hash D5B848F4 + sample 26: + time = 337333 + flags = 1 + data = length 192, hash B34427EA + sample 27: + time = 358666 + flags = 1 + data = length 199, hash C2599BB5 + sample 28: + time = 380000 + flags = 1 + data = length 195, hash BFD83194 + sample 29: + time = 401333 + flags = 1 + data = length 199, hash C9A7F7CA + sample 30: + time = 422666 + flags = 1 + data = length 44, hash 5D76EAD6 + sample 31: + time = 434666 + flags = 1 + data = length 43, hash 8619C423 + sample 32: + time = 437333 + flags = 1 + data = length 43, hash E490BBE + sample 33: + time = 440000 + flags = 1 + data = length 53, hash 8A557CAE + sample 34: + time = 442666 + flags = 1 + data = length 56, hash 81007BBA + sample 35: + time = 445333 + flags = 1 + data = length 56, hash 4E4DD67F + sample 36: + time = 448000 + flags = 1 + data = length 222, hash 414188AB + sample 37: + time = 460000 + flags = 1 + data = length 202, hash 67A07D30 + sample 38: + time = 481333 + flags = 1 + data = length 200, hash E357D853 + sample 39: + time = 502666 + flags = 1 + data = length 203, hash 4653DC90 + sample 40: + time = 524000 + flags = 1 + data = length 192, hash A65E6C09 + sample 41: + time = 545333 + flags = 1 + data = length 202, hash FBEAC508 + sample 42: + time = 566666 + flags = 1 + data = length 202, hash E9B7B59F + sample 43: + time = 588000 + flags = 1 + data = length 204, hash E24AA78E + sample 44: + time = 609333 + flags = 1 + data = length 41, hash 3FBC5216 + sample 45: + time = 621333 + flags = 1 + data = length 47, hash 153FBC55 + sample 46: + time = 624000 + flags = 1 + data = length 42, hash 2B493D6C + sample 47: + time = 626666 + flags = 1 + data = length 42, hash 8303BEE3 + sample 48: + time = 629333 + flags = 1 + data = length 62, hash 71AEE50B + sample 49: + time = 632000 + flags = 1 + data = length 54, hash 52F61908 + sample 50: + time = 634666 + flags = 1 + data = length 45, hash 7BD3E3A1 + sample 51: + time = 637333 + flags = 1 + data = length 41, hash E0F65472 + sample 52: + time = 640000 + flags = 1 + data = length 45, hash 41838675 + sample 53: + time = 642666 + flags = 1 + data = length 44, hash FCBC2147 + sample 54: + time = 645333 + flags = 1 + data = length 45, hash 1A5987E3 + sample 55: + time = 648000 + flags = 1 + data = length 43, hash 99074864 + sample 56: + time = 650666 + flags = 1 + data = length 57, hash D4A9B60A + sample 57: + time = 653333 + flags = 1 + data = length 52, hash 302129DA + sample 58: + time = 656000 + flags = 1 + data = length 57, hash D8DD99C0 + sample 59: + time = 658666 + flags = 1 + data = length 206, hash F4B9EF26 + sample 60: + time = 670666 + flags = 1 + data = length 197, hash 7B8ACC8A + sample 61: + time = 692000 + flags = 1 + data = length 186, hash 161027CB + sample 62: + time = 713333 + flags = 1 + data = length 186, hash 1D6871B6 + sample 63: + time = 734666 + flags = 1 + data = length 201, hash 536E9FDB + sample 64: + time = 756000 + flags = 1 + data = length 192, hash D38EFAC5 + sample 65: + time = 777333 + flags = 1 + data = length 194, hash 4B394EF3 + sample 66: + time = 798666 + flags = 1 + data = length 206, hash 1B31BA99 + sample 67: + time = 820000 + flags = 1 + data = length 212, hash AD061F43 + sample 68: + time = 841333 + flags = 1 + data = length 180, hash 6D1F7481 + sample 69: + time = 862666 + flags = 1 + data = length 195, hash D80B21F + sample 70: + time = 884000 + flags = 1 + data = length 186, hash D367882 + sample 71: + time = 905333 + flags = 1 + data = length 195, hash 2722159A + sample 72: + time = 926666 + flags = 1 + data = length 199, hash 10CEE97A + sample 73: + time = 948000 + flags = 1 + data = length 191, hash 2CF9FB3F + sample 74: + time = 969333 + flags = 1 + data = length 197, hash A725DA0 + sample 75: + time = 990666 + flags = 1 + data = length 211, hash D4E5DB9E + sample 76: + time = 1012000 + flags = 1 + data = length 189, hash 1A90F496 + sample 77: + time = 1033333 + flags = 1 + data = length 187, hash 44DB2689 + sample 78: + time = 1054666 + flags = 1 + data = length 197, hash 6D3E5117 + sample 79: + time = 1076000 + flags = 1 + data = length 208, hash 5B57B288 + sample 80: + time = 1097333 + flags = 1 + data = length 198, hash D5FC05 + sample 81: + time = 1118666 + flags = 1 + data = length 192, hash 350BBA45 + sample 82: + time = 1140000 + flags = 1 + data = length 195, hash 5F96F2A8 + sample 83: + time = 1161333 + flags = 1 + data = length 202, hash 61D7CC33 + sample 84: + time = 1182666 + flags = 1 + data = length 202, hash 49D335F2 + sample 85: + time = 1204000 + flags = 1 + data = length 192, hash 2FE9CB1A + sample 86: + time = 1225333 + flags = 1 + data = length 201, hash BF0763B2 + sample 87: + time = 1246666 + flags = 1 + data = length 184, hash AD047421 + sample 88: + time = 1268000 + flags = 1 + data = length 196, hash F9088F14 + sample 89: + time = 1289333 + flags = 1 + data = length 190, hash AC6D38FD + sample 90: + time = 1310666 + flags = 1 + data = length 195, hash 8D1A66D2 + sample 91: + time = 1332000 + flags = 1 + data = length 197, hash B46BFB6B + sample 92: + time = 1353333 + flags = 1 + data = length 195, hash D9761F23 + sample 93: + time = 1374666 + flags = 1 + data = length 204, hash 3391B617 + sample 94: + time = 1396000 + flags = 1 + data = length 42, hash 33A1FB52 + sample 95: + time = 1408000 + flags = 1 + data = length 44, hash 408B146E + sample 96: + time = 1410666 + flags = 1 + data = length 44, hash 171C7E0D + sample 97: + time = 1413333 + flags = 1 + data = length 54, hash 6307E16C + sample 98: + time = 1416000 + flags = 1 + data = length 53, hash 4A319572 + sample 99: + time = 1418666 + flags = 1 + data = length 215, hash BA9C445C + sample 100: + time = 1430666 + flags = 1 + data = length 201, hash 3120D234 + sample 101: + time = 1452000 + flags = 1 + data = length 187, hash DB44993C + sample 102: + time = 1473333 + flags = 1 + data = length 196, hash CF2002D7 + sample 103: + time = 1494666 + flags = 1 + data = length 185, hash E03B5D7 + sample 104: + time = 1516000 + flags = 1 + data = length 187, hash DA399A2C + sample 105: + time = 1537333 + flags = 1 + data = length 191, hash 292AA0DB + sample 106: + time = 1558666 + flags = 1 + data = length 201, hash 221910E0 + sample 107: + time = 1580000 + flags = 1 + data = length 194, hash F4ED7821 + sample 108: + time = 1601333 + flags = 1 + data = length 43, hash FDDA515E + sample 109: + time = 1613333 + flags = 1 + data = length 42, hash F3571C0A + sample 110: + time = 1616000 + flags = 1 + data = length 38, hash 39F910B3 + sample 111: + time = 1618666 + flags = 1 + data = length 41, hash 2D189531 + sample 112: + time = 1621333 + flags = 1 + data = length 43, hash 1F7574DB + sample 113: + time = 1624000 + flags = 1 + data = length 43, hash 644D15E5 + sample 114: + time = 1626666 + flags = 1 + data = length 49, hash E8A0878 + sample 115: + time = 1629333 + flags = 1 + data = length 55, hash DFF2046D + sample 116: + time = 1632000 + flags = 1 + data = length 49, hash 9FB8A23 + sample 117: + time = 1634666 + flags = 1 + data = length 41, hash E3E15E3B + sample 118: + time = 1637333 + flags = 1 + data = length 42, hash E5D17A32 + sample 119: + time = 1640000 + flags = 1 + data = length 42, hash F308B653 + sample 120: + time = 1642666 + flags = 1 + data = length 55, hash BB750D76 + sample 121: + time = 1645333 + flags = 1 + data = length 51, hash 96772ABF + sample 122: + time = 1648000 + flags = 1 + data = length 197, hash E4524346 + sample 123: + time = 1660000 + flags = 1 + data = length 188, hash AC3E1BB5 + sample 124: + time = 1681333 + flags = 1 + data = length 195, hash F56DB8A5 + sample 125: + time = 1702666 + flags = 1 + data = length 198, hash C8970FF7 + sample 126: + time = 1724000 + flags = 1 + data = length 202, hash AF425C68 + sample 127: + time = 1745333 + flags = 1 + data = length 196, hash 4215D839 + sample 128: + time = 1766666 + flags = 1 + data = length 204, hash DB9BE8E3 + sample 129: + time = 1788000 + flags = 1 + data = length 206, hash E5B20AB8 + sample 130: + time = 1809333 + flags = 1 + data = length 209, hash D7F47B95 + sample 131: + time = 1830666 + flags = 1 + data = length 193, hash FB54FB05 + sample 132: + time = 1852000 + flags = 1 + data = length 199, hash D99C3106 + sample 133: + time = 1873333 + flags = 1 + data = length 206, hash 253885B9 + sample 134: + time = 1894666 + flags = 1 + data = length 191, hash FBDD8162 + sample 135: + time = 1916000 + flags = 1 + data = length 183, hash 7290332F + sample 136: + time = 1937333 + flags = 1 + data = length 189, hash 1A9DC3DE + sample 137: + time = 1958666 + flags = 1 + data = length 201, hash 5D936764 + sample 138: + time = 1980000 + flags = 1 + data = length 193, hash 6B03E75E + sample 139: + time = 2001333 + flags = 1 + data = length 199, hash 8A21BA83 + sample 140: + time = 2022666 + flags = 1 + data = length 41, hash E6362210 + sample 141: + time = 2034666 + flags = 1 + data = length 43, hash 36A57B44 + sample 142: + time = 2037333 + flags = 1 + data = length 43, hash E51797D5 + sample 143: + time = 2040000 + flags = 1 + data = length 43, hash 1F336C72 + sample 144: + time = 2042666 + flags = 1 + data = length 42, hash 201AD367 + sample 145: + time = 2045333 + flags = 1 + data = length 50, hash 606CCD6 + sample 146: + time = 2048000 + flags = 1 + data = length 56, hash B15EBD7A + sample 147: + time = 2050666 + flags = 1 + data = length 212, hash 273B8D22 + sample 148: + time = 2062666 + flags = 1 + data = length 194, hash 44F9CE1 + sample 149: + time = 2084000 + flags = 1 + data = length 195, hash EDF9EBA1 + sample 150: + time = 2105333 + flags = 1 + data = length 194, hash CE9F2D26 + sample 151: + time = 2126666 + flags = 1 + data = length 192, hash 204F8A23 + sample 152: + time = 2148000 + flags = 1 + data = length 206, hash DFA57E67 + sample 153: + time = 2169333 + flags = 1 + data = length 196, hash 3CF084AB + sample 154: + time = 2190666 + flags = 1 + data = length 202, hash 2AF75C08 + sample 155: + time = 2212000 + flags = 1 + data = length 203, hash 748EAF7 + sample 156: + time = 2233333 + flags = 1 + data = length 205, hash ED82379D + sample 157: + time = 2254666 + flags = 1 + data = length 193, hash 61F26F22 + sample 158: + time = 2276000 + flags = 1 + data = length 189, hash 85EF1D20 + sample 159: + time = 2297333 + flags = 1 + data = length 187, hash 25E41FBF + sample 160: + time = 2318666 + flags = 1 + data = length 199, hash F365808 + sample 161: + time = 2340000 + flags = 1 + data = length 197, hash 94205329 + sample 162: + time = 2361333 + flags = 1 + data = length 201, hash FA2B2055 + sample 163: + time = 2382666 + flags = 1 + data = length 194, hash AF95381F + sample 164: + time = 2404000 + flags = 1 + data = length 201, hash 923D3534 + sample 165: + time = 2425333 + flags = 1 + data = length 198, hash 35F84C2E + sample 166: + time = 2446666 + flags = 1 + data = length 204, hash 6642CA40 + sample 167: + time = 2468000 + flags = 1 + data = length 183, hash 3E2DC6BE + sample 168: + time = 2489333 + flags = 1 + data = length 197, hash B1E458CE + sample 169: + time = 2510666 + flags = 1 + data = length 193, hash E9218C84 + sample 170: + time = 2532000 + flags = 1 + data = length 192, hash FEF08D4B + sample 171: + time = 2553333 + flags = 1 + data = length 201, hash FC411147 + sample 172: + time = 2574666 + flags = 1 + data = length 218, hash 86893464 + sample 173: + time = 2596000 + flags = 1 + data = length 226, hash 31C5320 + sample 174: + time = 2617333 + flags = 1 + data = length 233, hash 9432BEE5 + sample 175: + time = 2638666 + flags = 1 + data = length 213, hash B3FCC53E + sample 176: + time = 2660000 + flags = 1 + data = length 204, hash D70DD5A2 + sample 177: + time = 2681333 + flags = 1 + data = length 212, hash A4EF1B69 + sample 178: + time = 2702666 + flags = 1 + data = length 203, hash 8B0748B5 + sample 179: + time = 2724000 + flags = 1 + data = length 149, hash E455335B +tracksEnded = true diff --git a/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.1.dump b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.1.dump new file mode 100644 index 0000000000..1946984309 --- /dev/null +++ b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.1.dump @@ -0,0 +1,456 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=4005]] + getPosition(1) = [[timeUs=1, position=4005]] + getPosition(1370500) = [[timeUs=1370500, position=4005]] + getPosition(2741000) = [[timeUs=2741000, position=4005]] +numberOfTracks = 1 +track 0: + total output bytes = 17598 + sample count = 109 + format 0: + averageBitrate = 112000 + sampleMimeType = audio/vorbis + channelCount = 2 + sampleRate = 48000 + initializationData: + data = length 30, hash 9A8FF207 + data = length 3832, hash 8A406249 + sample 0: + time = 896000 + flags = 1 + data = length 195, hash 2722159A + sample 1: + time = 917333 + flags = 1 + data = length 199, hash 10CEE97A + sample 2: + time = 938666 + flags = 1 + data = length 191, hash 2CF9FB3F + sample 3: + time = 960000 + flags = 1 + data = length 197, hash A725DA0 + sample 4: + time = 981333 + flags = 1 + data = length 211, hash D4E5DB9E + sample 5: + time = 1002666 + flags = 1 + data = length 189, hash 1A90F496 + sample 6: + time = 1024000 + flags = 1 + data = length 187, hash 44DB2689 + sample 7: + time = 1045333 + flags = 1 + data = length 197, hash 6D3E5117 + sample 8: + time = 1066666 + flags = 1 + data = length 208, hash 5B57B288 + sample 9: + time = 1088000 + flags = 1 + data = length 198, hash D5FC05 + sample 10: + time = 1109333 + flags = 1 + data = length 192, hash 350BBA45 + sample 11: + time = 1130666 + flags = 1 + data = length 195, hash 5F96F2A8 + sample 12: + time = 1152000 + flags = 1 + data = length 202, hash 61D7CC33 + sample 13: + time = 1173333 + flags = 1 + data = length 202, hash 49D335F2 + sample 14: + time = 1194666 + flags = 1 + data = length 192, hash 2FE9CB1A + sample 15: + time = 1216000 + flags = 1 + data = length 201, hash BF0763B2 + sample 16: + time = 1237333 + flags = 1 + data = length 184, hash AD047421 + sample 17: + time = 1258666 + flags = 1 + data = length 196, hash F9088F14 + sample 18: + time = 1280000 + flags = 1 + data = length 190, hash AC6D38FD + sample 19: + time = 1301333 + flags = 1 + data = length 195, hash 8D1A66D2 + sample 20: + time = 1322666 + flags = 1 + data = length 197, hash B46BFB6B + sample 21: + time = 1344000 + flags = 1 + data = length 195, hash D9761F23 + sample 22: + time = 1365333 + flags = 1 + data = length 204, hash 3391B617 + sample 23: + time = 1386666 + flags = 1 + data = length 42, hash 33A1FB52 + sample 24: + time = 1398666 + flags = 1 + data = length 44, hash 408B146E + sample 25: + time = 1401333 + flags = 1 + data = length 44, hash 171C7E0D + sample 26: + time = 1404000 + flags = 1 + data = length 54, hash 6307E16C + sample 27: + time = 1406666 + flags = 1 + data = length 53, hash 4A319572 + sample 28: + time = 1409333 + flags = 1 + data = length 215, hash BA9C445C + sample 29: + time = 1421333 + flags = 1 + data = length 201, hash 3120D234 + sample 30: + time = 1442666 + flags = 1 + data = length 187, hash DB44993C + sample 31: + time = 1464000 + flags = 1 + data = length 196, hash CF2002D7 + sample 32: + time = 1485333 + flags = 1 + data = length 185, hash E03B5D7 + sample 33: + time = 1506666 + flags = 1 + data = length 187, hash DA399A2C + sample 34: + time = 1528000 + flags = 1 + data = length 191, hash 292AA0DB + sample 35: + time = 1549333 + flags = 1 + data = length 201, hash 221910E0 + sample 36: + time = 1570666 + flags = 1 + data = length 194, hash F4ED7821 + sample 37: + time = 1592000 + flags = 1 + data = length 43, hash FDDA515E + sample 38: + time = 1604000 + flags = 1 + data = length 42, hash F3571C0A + sample 39: + time = 1606666 + flags = 1 + data = length 38, hash 39F910B3 + sample 40: + time = 1609333 + flags = 1 + data = length 41, hash 2D189531 + sample 41: + time = 1612000 + flags = 1 + data = length 43, hash 1F7574DB + sample 42: + time = 1614666 + flags = 1 + data = length 43, hash 644D15E5 + sample 43: + time = 1617333 + flags = 1 + data = length 49, hash E8A0878 + sample 44: + time = 1620000 + flags = 1 + data = length 55, hash DFF2046D + sample 45: + time = 1622666 + flags = 1 + data = length 49, hash 9FB8A23 + sample 46: + time = 1625333 + flags = 1 + data = length 41, hash E3E15E3B + sample 47: + time = 1628000 + flags = 1 + data = length 42, hash E5D17A32 + sample 48: + time = 1630666 + flags = 1 + data = length 42, hash F308B653 + sample 49: + time = 1633333 + flags = 1 + data = length 55, hash BB750D76 + sample 50: + time = 1636000 + flags = 1 + data = length 51, hash 96772ABF + sample 51: + time = 1638666 + flags = 1 + data = length 197, hash E4524346 + sample 52: + time = 1650666 + flags = 1 + data = length 188, hash AC3E1BB5 + sample 53: + time = 1672000 + flags = 1 + data = length 195, hash F56DB8A5 + sample 54: + time = 1693333 + flags = 1 + data = length 198, hash C8970FF7 + sample 55: + time = 1714666 + flags = 1 + data = length 202, hash AF425C68 + sample 56: + time = 1736000 + flags = 1 + data = length 196, hash 4215D839 + sample 57: + time = 1757333 + flags = 1 + data = length 204, hash DB9BE8E3 + sample 58: + time = 1778666 + flags = 1 + data = length 206, hash E5B20AB8 + sample 59: + time = 1800000 + flags = 1 + data = length 209, hash D7F47B95 + sample 60: + time = 1821333 + flags = 1 + data = length 193, hash FB54FB05 + sample 61: + time = 1842666 + flags = 1 + data = length 199, hash D99C3106 + sample 62: + time = 1864000 + flags = 1 + data = length 206, hash 253885B9 + sample 63: + time = 1885333 + flags = 1 + data = length 191, hash FBDD8162 + sample 64: + time = 1906666 + flags = 1 + data = length 183, hash 7290332F + sample 65: + time = 1928000 + flags = 1 + data = length 189, hash 1A9DC3DE + sample 66: + time = 1949333 + flags = 1 + data = length 201, hash 5D936764 + sample 67: + time = 1970666 + flags = 1 + data = length 193, hash 6B03E75E + sample 68: + time = 1992000 + flags = 1 + data = length 199, hash 8A21BA83 + sample 69: + time = 2013333 + flags = 1 + data = length 41, hash E6362210 + sample 70: + time = 2025333 + flags = 1 + data = length 43, hash 36A57B44 + sample 71: + time = 2028000 + flags = 1 + data = length 43, hash E51797D5 + sample 72: + time = 2030666 + flags = 1 + data = length 43, hash 1F336C72 + sample 73: + time = 2033333 + flags = 1 + data = length 42, hash 201AD367 + sample 74: + time = 2036000 + flags = 1 + data = length 50, hash 606CCD6 + sample 75: + time = 2038666 + flags = 1 + data = length 56, hash B15EBD7A + sample 76: + time = 2041333 + flags = 1 + data = length 212, hash 273B8D22 + sample 77: + time = 2053333 + flags = 1 + data = length 194, hash 44F9CE1 + sample 78: + time = 2074666 + flags = 1 + data = length 195, hash EDF9EBA1 + sample 79: + time = 2096000 + flags = 1 + data = length 194, hash CE9F2D26 + sample 80: + time = 2117333 + flags = 1 + data = length 192, hash 204F8A23 + sample 81: + time = 2138666 + flags = 1 + data = length 206, hash DFA57E67 + sample 82: + time = 2160000 + flags = 1 + data = length 196, hash 3CF084AB + sample 83: + time = 2181333 + flags = 1 + data = length 202, hash 2AF75C08 + sample 84: + time = 2202666 + flags = 1 + data = length 203, hash 748EAF7 + sample 85: + time = 2224000 + flags = 1 + data = length 205, hash ED82379D + sample 86: + time = 2245333 + flags = 1 + data = length 193, hash 61F26F22 + sample 87: + time = 2266666 + flags = 1 + data = length 189, hash 85EF1D20 + sample 88: + time = 2288000 + flags = 1 + data = length 187, hash 25E41FBF + sample 89: + time = 2309333 + flags = 1 + data = length 199, hash F365808 + sample 90: + time = 2330666 + flags = 1 + data = length 197, hash 94205329 + sample 91: + time = 2352000 + flags = 1 + data = length 201, hash FA2B2055 + sample 92: + time = 2373333 + flags = 1 + data = length 194, hash AF95381F + sample 93: + time = 2394666 + flags = 1 + data = length 201, hash 923D3534 + sample 94: + time = 2416000 + flags = 1 + data = length 198, hash 35F84C2E + sample 95: + time = 2437333 + flags = 1 + data = length 204, hash 6642CA40 + sample 96: + time = 2458666 + flags = 1 + data = length 183, hash 3E2DC6BE + sample 97: + time = 2480000 + flags = 1 + data = length 197, hash B1E458CE + sample 98: + time = 2501333 + flags = 1 + data = length 193, hash E9218C84 + sample 99: + time = 2522666 + flags = 1 + data = length 192, hash FEF08D4B + sample 100: + time = 2544000 + flags = 1 + data = length 201, hash FC411147 + sample 101: + time = 2565333 + flags = 1 + data = length 218, hash 86893464 + sample 102: + time = 2586666 + flags = 1 + data = length 226, hash 31C5320 + sample 103: + time = 2608000 + flags = 1 + data = length 233, hash 9432BEE5 + sample 104: + time = 2629333 + flags = 1 + data = length 213, hash B3FCC53E + sample 105: + time = 2650666 + flags = 1 + data = length 204, hash D70DD5A2 + sample 106: + time = 2672000 + flags = 1 + data = length 212, hash A4EF1B69 + sample 107: + time = 2693333 + flags = 1 + data = length 203, hash 8B0748B5 + sample 108: + time = 2714666 + flags = 1 + data = length 149, hash E455335B +tracksEnded = true diff --git a/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.2.dump b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.2.dump new file mode 100644 index 0000000000..b936c3621a --- /dev/null +++ b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.2.dump @@ -0,0 +1,216 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=4005]] + getPosition(1) = [[timeUs=1, position=4005]] + getPosition(1370500) = [[timeUs=1370500, position=4005]] + getPosition(2741000) = [[timeUs=2741000, position=4005]] +numberOfTracks = 1 +track 0: + total output bytes = 8658 + sample count = 49 + format 0: + averageBitrate = 112000 + sampleMimeType = audio/vorbis + channelCount = 2 + sampleRate = 48000 + initializationData: + data = length 30, hash 9A8FF207 + data = length 3832, hash 8A406249 + sample 0: + time = 1821333 + flags = 1 + data = length 193, hash FB54FB05 + sample 1: + time = 1842666 + flags = 1 + data = length 199, hash D99C3106 + sample 2: + time = 1864000 + flags = 1 + data = length 206, hash 253885B9 + sample 3: + time = 1885333 + flags = 1 + data = length 191, hash FBDD8162 + sample 4: + time = 1906666 + flags = 1 + data = length 183, hash 7290332F + sample 5: + time = 1928000 + flags = 1 + data = length 189, hash 1A9DC3DE + sample 6: + time = 1949333 + flags = 1 + data = length 201, hash 5D936764 + sample 7: + time = 1970666 + flags = 1 + data = length 193, hash 6B03E75E + sample 8: + time = 1992000 + flags = 1 + data = length 199, hash 8A21BA83 + sample 9: + time = 2013333 + flags = 1 + data = length 41, hash E6362210 + sample 10: + time = 2025333 + flags = 1 + data = length 43, hash 36A57B44 + sample 11: + time = 2028000 + flags = 1 + data = length 43, hash E51797D5 + sample 12: + time = 2030666 + flags = 1 + data = length 43, hash 1F336C72 + sample 13: + time = 2033333 + flags = 1 + data = length 42, hash 201AD367 + sample 14: + time = 2036000 + flags = 1 + data = length 50, hash 606CCD6 + sample 15: + time = 2038666 + flags = 1 + data = length 56, hash B15EBD7A + sample 16: + time = 2041333 + flags = 1 + data = length 212, hash 273B8D22 + sample 17: + time = 2053333 + flags = 1 + data = length 194, hash 44F9CE1 + sample 18: + time = 2074666 + flags = 1 + data = length 195, hash EDF9EBA1 + sample 19: + time = 2096000 + flags = 1 + data = length 194, hash CE9F2D26 + sample 20: + time = 2117333 + flags = 1 + data = length 192, hash 204F8A23 + sample 21: + time = 2138666 + flags = 1 + data = length 206, hash DFA57E67 + sample 22: + time = 2160000 + flags = 1 + data = length 196, hash 3CF084AB + sample 23: + time = 2181333 + flags = 1 + data = length 202, hash 2AF75C08 + sample 24: + time = 2202666 + flags = 1 + data = length 203, hash 748EAF7 + sample 25: + time = 2224000 + flags = 1 + data = length 205, hash ED82379D + sample 26: + time = 2245333 + flags = 1 + data = length 193, hash 61F26F22 + sample 27: + time = 2266666 + flags = 1 + data = length 189, hash 85EF1D20 + sample 28: + time = 2288000 + flags = 1 + data = length 187, hash 25E41FBF + sample 29: + time = 2309333 + flags = 1 + data = length 199, hash F365808 + sample 30: + time = 2330666 + flags = 1 + data = length 197, hash 94205329 + sample 31: + time = 2352000 + flags = 1 + data = length 201, hash FA2B2055 + sample 32: + time = 2373333 + flags = 1 + data = length 194, hash AF95381F + sample 33: + time = 2394666 + flags = 1 + data = length 201, hash 923D3534 + sample 34: + time = 2416000 + flags = 1 + data = length 198, hash 35F84C2E + sample 35: + time = 2437333 + flags = 1 + data = length 204, hash 6642CA40 + sample 36: + time = 2458666 + flags = 1 + data = length 183, hash 3E2DC6BE + sample 37: + time = 2480000 + flags = 1 + data = length 197, hash B1E458CE + sample 38: + time = 2501333 + flags = 1 + data = length 193, hash E9218C84 + sample 39: + time = 2522666 + flags = 1 + data = length 192, hash FEF08D4B + sample 40: + time = 2544000 + flags = 1 + data = length 201, hash FC411147 + sample 41: + time = 2565333 + flags = 1 + data = length 218, hash 86893464 + sample 42: + time = 2586666 + flags = 1 + data = length 226, hash 31C5320 + sample 43: + time = 2608000 + flags = 1 + data = length 233, hash 9432BEE5 + sample 44: + time = 2629333 + flags = 1 + data = length 213, hash B3FCC53E + sample 45: + time = 2650666 + flags = 1 + data = length 204, hash D70DD5A2 + sample 46: + time = 2672000 + flags = 1 + data = length 212, hash A4EF1B69 + sample 47: + time = 2693333 + flags = 1 + data = length 203, hash 8B0748B5 + sample 48: + time = 2714666 + flags = 1 + data = length 149, hash E455335B +tracksEnded = true diff --git a/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.3.dump b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.3.dump new file mode 100644 index 0000000000..91fc8a7b09 --- /dev/null +++ b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.3.dump @@ -0,0 +1,20 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=4005]] + getPosition(1) = [[timeUs=1, position=4005]] + getPosition(1370500) = [[timeUs=1370500, position=4005]] + getPosition(2741000) = [[timeUs=2741000, position=4005]] +numberOfTracks = 1 +track 0: + total output bytes = 0 + sample count = 0 + format 0: + averageBitrate = 112000 + sampleMimeType = audio/vorbis + channelCount = 2 + sampleRate = 48000 + initializationData: + data = length 30, hash 9A8FF207 + data = length 3832, hash 8A406249 +tracksEnded = true diff --git a/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.unknown_length.dump b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.unknown_length.dump new file mode 100644 index 0000000000..9830a08357 --- /dev/null +++ b/testdata/src/test/assets/ogg/bear_vorbis_gap.ogg.unknown_length.dump @@ -0,0 +1,737 @@ +seekMap: + isSeekable = false + duration = UNSET TIME + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + total output bytes = 26873 + sample count = 180 + format 0: + averageBitrate = 112000 + sampleMimeType = audio/vorbis + channelCount = 2 + sampleRate = 48000 + initializationData: + data = length 30, hash 9A8FF207 + data = length 3832, hash 8A406249 + sample 0: + time = 0 + flags = 1 + data = length 49, hash 2FFF94F0 + sample 1: + time = 0 + flags = 1 + data = length 44, hash 3946418A + sample 2: + time = 2666 + flags = 1 + data = length 55, hash 2A0B878E + sample 3: + time = 5333 + flags = 1 + data = length 53, hash CC3B6879 + sample 4: + time = 8000 + flags = 1 + data = length 215, hash 106AE950 + sample 5: + time = 20000 + flags = 1 + data = length 192, hash 2B219F53 + sample 6: + time = 41333 + flags = 1 + data = length 197, hash FBC39422 + sample 7: + time = 62666 + flags = 1 + data = length 209, hash 386E8979 + sample 8: + time = 84000 + flags = 1 + data = length 42, hash E81162C1 + sample 9: + time = 96000 + flags = 1 + data = length 41, hash F15BEE36 + sample 10: + time = 98666 + flags = 1 + data = length 42, hash D67EB19 + sample 11: + time = 101333 + flags = 1 + data = length 42, hash F4DE4792 + sample 12: + time = 104000 + flags = 1 + data = length 53, hash 80F66AC3 + sample 13: + time = 106666 + flags = 1 + data = length 56, hash DCB9DFC4 + sample 14: + time = 109333 + flags = 1 + data = length 55, hash 4E0C4E9D + sample 15: + time = 112000 + flags = 1 + data = length 203, hash 176B6862 + sample 16: + time = 124000 + flags = 1 + data = length 193, hash AB13CB10 + sample 17: + time = 145333 + flags = 1 + data = length 203, hash DE63DE9F + sample 18: + time = 166666 + flags = 1 + data = length 194, hash 4A9508A2 + sample 19: + time = 188000 + flags = 1 + data = length 210, hash 196899B3 + sample 20: + time = 209333 + flags = 1 + data = length 195, hash B68407F1 + sample 21: + time = 230666 + flags = 1 + data = length 193, hash A1FA86E3 + sample 22: + time = 252000 + flags = 1 + data = length 194, hash 5C0B9343 + sample 23: + time = 273333 + flags = 1 + data = length 198, hash 789914B2 + sample 24: + time = 294666 + flags = 1 + data = length 183, hash 1B82D11F + sample 25: + time = 316000 + flags = 1 + data = length 199, hash D5B848F4 + sample 26: + time = 337333 + flags = 1 + data = length 192, hash B34427EA + sample 27: + time = 358666 + flags = 1 + data = length 199, hash C2599BB5 + sample 28: + time = 380000 + flags = 1 + data = length 195, hash BFD83194 + sample 29: + time = 401333 + flags = 1 + data = length 199, hash C9A7F7CA + sample 30: + time = 422666 + flags = 1 + data = length 44, hash 5D76EAD6 + sample 31: + time = 434666 + flags = 1 + data = length 43, hash 8619C423 + sample 32: + time = 437333 + flags = 1 + data = length 43, hash E490BBE + sample 33: + time = 440000 + flags = 1 + data = length 53, hash 8A557CAE + sample 34: + time = 442666 + flags = 1 + data = length 56, hash 81007BBA + sample 35: + time = 445333 + flags = 1 + data = length 56, hash 4E4DD67F + sample 36: + time = 448000 + flags = 1 + data = length 222, hash 414188AB + sample 37: + time = 460000 + flags = 1 + data = length 202, hash 67A07D30 + sample 38: + time = 481333 + flags = 1 + data = length 200, hash E357D853 + sample 39: + time = 502666 + flags = 1 + data = length 203, hash 4653DC90 + sample 40: + time = 524000 + flags = 1 + data = length 192, hash A65E6C09 + sample 41: + time = 545333 + flags = 1 + data = length 202, hash FBEAC508 + sample 42: + time = 566666 + flags = 1 + data = length 202, hash E9B7B59F + sample 43: + time = 588000 + flags = 1 + data = length 204, hash E24AA78E + sample 44: + time = 609333 + flags = 1 + data = length 41, hash 3FBC5216 + sample 45: + time = 621333 + flags = 1 + data = length 47, hash 153FBC55 + sample 46: + time = 624000 + flags = 1 + data = length 42, hash 2B493D6C + sample 47: + time = 626666 + flags = 1 + data = length 42, hash 8303BEE3 + sample 48: + time = 629333 + flags = 1 + data = length 62, hash 71AEE50B + sample 49: + time = 632000 + flags = 1 + data = length 54, hash 52F61908 + sample 50: + time = 634666 + flags = 1 + data = length 45, hash 7BD3E3A1 + sample 51: + time = 637333 + flags = 1 + data = length 41, hash E0F65472 + sample 52: + time = 640000 + flags = 1 + data = length 45, hash 41838675 + sample 53: + time = 642666 + flags = 1 + data = length 44, hash FCBC2147 + sample 54: + time = 645333 + flags = 1 + data = length 45, hash 1A5987E3 + sample 55: + time = 648000 + flags = 1 + data = length 43, hash 99074864 + sample 56: + time = 650666 + flags = 1 + data = length 57, hash D4A9B60A + sample 57: + time = 653333 + flags = 1 + data = length 52, hash 302129DA + sample 58: + time = 656000 + flags = 1 + data = length 57, hash D8DD99C0 + sample 59: + time = 658666 + flags = 1 + data = length 206, hash F4B9EF26 + sample 60: + time = 670666 + flags = 1 + data = length 197, hash 7B8ACC8A + sample 61: + time = 692000 + flags = 1 + data = length 186, hash 161027CB + sample 62: + time = 713333 + flags = 1 + data = length 186, hash 1D6871B6 + sample 63: + time = 734666 + flags = 1 + data = length 201, hash 536E9FDB + sample 64: + time = 756000 + flags = 1 + data = length 192, hash D38EFAC5 + sample 65: + time = 777333 + flags = 1 + data = length 194, hash 4B394EF3 + sample 66: + time = 798666 + flags = 1 + data = length 206, hash 1B31BA99 + sample 67: + time = 820000 + flags = 1 + data = length 212, hash AD061F43 + sample 68: + time = 841333 + flags = 1 + data = length 180, hash 6D1F7481 + sample 69: + time = 862666 + flags = 1 + data = length 195, hash D80B21F + sample 70: + time = 884000 + flags = 1 + data = length 186, hash D367882 + sample 71: + time = 905333 + flags = 1 + data = length 195, hash 2722159A + sample 72: + time = 926666 + flags = 1 + data = length 199, hash 10CEE97A + sample 73: + time = 948000 + flags = 1 + data = length 191, hash 2CF9FB3F + sample 74: + time = 969333 + flags = 1 + data = length 197, hash A725DA0 + sample 75: + time = 990666 + flags = 1 + data = length 211, hash D4E5DB9E + sample 76: + time = 1012000 + flags = 1 + data = length 189, hash 1A90F496 + sample 77: + time = 1033333 + flags = 1 + data = length 187, hash 44DB2689 + sample 78: + time = 1054666 + flags = 1 + data = length 197, hash 6D3E5117 + sample 79: + time = 1076000 + flags = 1 + data = length 208, hash 5B57B288 + sample 80: + time = 1097333 + flags = 1 + data = length 198, hash D5FC05 + sample 81: + time = 1118666 + flags = 1 + data = length 192, hash 350BBA45 + sample 82: + time = 1140000 + flags = 1 + data = length 195, hash 5F96F2A8 + sample 83: + time = 1161333 + flags = 1 + data = length 202, hash 61D7CC33 + sample 84: + time = 1182666 + flags = 1 + data = length 202, hash 49D335F2 + sample 85: + time = 1204000 + flags = 1 + data = length 192, hash 2FE9CB1A + sample 86: + time = 1225333 + flags = 1 + data = length 201, hash BF0763B2 + sample 87: + time = 1246666 + flags = 1 + data = length 184, hash AD047421 + sample 88: + time = 1268000 + flags = 1 + data = length 196, hash F9088F14 + sample 89: + time = 1289333 + flags = 1 + data = length 190, hash AC6D38FD + sample 90: + time = 1310666 + flags = 1 + data = length 195, hash 8D1A66D2 + sample 91: + time = 1332000 + flags = 1 + data = length 197, hash B46BFB6B + sample 92: + time = 1353333 + flags = 1 + data = length 195, hash D9761F23 + sample 93: + time = 1374666 + flags = 1 + data = length 204, hash 3391B617 + sample 94: + time = 1396000 + flags = 1 + data = length 42, hash 33A1FB52 + sample 95: + time = 1408000 + flags = 1 + data = length 44, hash 408B146E + sample 96: + time = 1410666 + flags = 1 + data = length 44, hash 171C7E0D + sample 97: + time = 1413333 + flags = 1 + data = length 54, hash 6307E16C + sample 98: + time = 1416000 + flags = 1 + data = length 53, hash 4A319572 + sample 99: + time = 1418666 + flags = 1 + data = length 215, hash BA9C445C + sample 100: + time = 1430666 + flags = 1 + data = length 201, hash 3120D234 + sample 101: + time = 1452000 + flags = 1 + data = length 187, hash DB44993C + sample 102: + time = 1473333 + flags = 1 + data = length 196, hash CF2002D7 + sample 103: + time = 1494666 + flags = 1 + data = length 185, hash E03B5D7 + sample 104: + time = 1516000 + flags = 1 + data = length 187, hash DA399A2C + sample 105: + time = 1537333 + flags = 1 + data = length 191, hash 292AA0DB + sample 106: + time = 1558666 + flags = 1 + data = length 201, hash 221910E0 + sample 107: + time = 1580000 + flags = 1 + data = length 194, hash F4ED7821 + sample 108: + time = 1601333 + flags = 1 + data = length 43, hash FDDA515E + sample 109: + time = 1613333 + flags = 1 + data = length 42, hash F3571C0A + sample 110: + time = 1616000 + flags = 1 + data = length 38, hash 39F910B3 + sample 111: + time = 1618666 + flags = 1 + data = length 41, hash 2D189531 + sample 112: + time = 1621333 + flags = 1 + data = length 43, hash 1F7574DB + sample 113: + time = 1624000 + flags = 1 + data = length 43, hash 644D15E5 + sample 114: + time = 1626666 + flags = 1 + data = length 49, hash E8A0878 + sample 115: + time = 1629333 + flags = 1 + data = length 55, hash DFF2046D + sample 116: + time = 1632000 + flags = 1 + data = length 49, hash 9FB8A23 + sample 117: + time = 1634666 + flags = 1 + data = length 41, hash E3E15E3B + sample 118: + time = 1637333 + flags = 1 + data = length 42, hash E5D17A32 + sample 119: + time = 1640000 + flags = 1 + data = length 42, hash F308B653 + sample 120: + time = 1642666 + flags = 1 + data = length 55, hash BB750D76 + sample 121: + time = 1645333 + flags = 1 + data = length 51, hash 96772ABF + sample 122: + time = 1648000 + flags = 1 + data = length 197, hash E4524346 + sample 123: + time = 1660000 + flags = 1 + data = length 188, hash AC3E1BB5 + sample 124: + time = 1681333 + flags = 1 + data = length 195, hash F56DB8A5 + sample 125: + time = 1702666 + flags = 1 + data = length 198, hash C8970FF7 + sample 126: + time = 1724000 + flags = 1 + data = length 202, hash AF425C68 + sample 127: + time = 1745333 + flags = 1 + data = length 196, hash 4215D839 + sample 128: + time = 1766666 + flags = 1 + data = length 204, hash DB9BE8E3 + sample 129: + time = 1788000 + flags = 1 + data = length 206, hash E5B20AB8 + sample 130: + time = 1809333 + flags = 1 + data = length 209, hash D7F47B95 + sample 131: + time = 1830666 + flags = 1 + data = length 193, hash FB54FB05 + sample 132: + time = 1852000 + flags = 1 + data = length 199, hash D99C3106 + sample 133: + time = 1873333 + flags = 1 + data = length 206, hash 253885B9 + sample 134: + time = 1894666 + flags = 1 + data = length 191, hash FBDD8162 + sample 135: + time = 1916000 + flags = 1 + data = length 183, hash 7290332F + sample 136: + time = 1937333 + flags = 1 + data = length 189, hash 1A9DC3DE + sample 137: + time = 1958666 + flags = 1 + data = length 201, hash 5D936764 + sample 138: + time = 1980000 + flags = 1 + data = length 193, hash 6B03E75E + sample 139: + time = 2001333 + flags = 1 + data = length 199, hash 8A21BA83 + sample 140: + time = 2022666 + flags = 1 + data = length 41, hash E6362210 + sample 141: + time = 2034666 + flags = 1 + data = length 43, hash 36A57B44 + sample 142: + time = 2037333 + flags = 1 + data = length 43, hash E51797D5 + sample 143: + time = 2040000 + flags = 1 + data = length 43, hash 1F336C72 + sample 144: + time = 2042666 + flags = 1 + data = length 42, hash 201AD367 + sample 145: + time = 2045333 + flags = 1 + data = length 50, hash 606CCD6 + sample 146: + time = 2048000 + flags = 1 + data = length 56, hash B15EBD7A + sample 147: + time = 2050666 + flags = 1 + data = length 212, hash 273B8D22 + sample 148: + time = 2062666 + flags = 1 + data = length 194, hash 44F9CE1 + sample 149: + time = 2084000 + flags = 1 + data = length 195, hash EDF9EBA1 + sample 150: + time = 2105333 + flags = 1 + data = length 194, hash CE9F2D26 + sample 151: + time = 2126666 + flags = 1 + data = length 192, hash 204F8A23 + sample 152: + time = 2148000 + flags = 1 + data = length 206, hash DFA57E67 + sample 153: + time = 2169333 + flags = 1 + data = length 196, hash 3CF084AB + sample 154: + time = 2190666 + flags = 1 + data = length 202, hash 2AF75C08 + sample 155: + time = 2212000 + flags = 1 + data = length 203, hash 748EAF7 + sample 156: + time = 2233333 + flags = 1 + data = length 205, hash ED82379D + sample 157: + time = 2254666 + flags = 1 + data = length 193, hash 61F26F22 + sample 158: + time = 2276000 + flags = 1 + data = length 189, hash 85EF1D20 + sample 159: + time = 2297333 + flags = 1 + data = length 187, hash 25E41FBF + sample 160: + time = 2318666 + flags = 1 + data = length 199, hash F365808 + sample 161: + time = 2340000 + flags = 1 + data = length 197, hash 94205329 + sample 162: + time = 2361333 + flags = 1 + data = length 201, hash FA2B2055 + sample 163: + time = 2382666 + flags = 1 + data = length 194, hash AF95381F + sample 164: + time = 2404000 + flags = 1 + data = length 201, hash 923D3534 + sample 165: + time = 2425333 + flags = 1 + data = length 198, hash 35F84C2E + sample 166: + time = 2446666 + flags = 1 + data = length 204, hash 6642CA40 + sample 167: + time = 2468000 + flags = 1 + data = length 183, hash 3E2DC6BE + sample 168: + time = 2489333 + flags = 1 + data = length 197, hash B1E458CE + sample 169: + time = 2510666 + flags = 1 + data = length 193, hash E9218C84 + sample 170: + time = 2532000 + flags = 1 + data = length 192, hash FEF08D4B + sample 171: + time = 2553333 + flags = 1 + data = length 201, hash FC411147 + sample 172: + time = 2574666 + flags = 1 + data = length 218, hash 86893464 + sample 173: + time = 2596000 + flags = 1 + data = length 226, hash 31C5320 + sample 174: + time = 2617333 + flags = 1 + data = length 233, hash 9432BEE5 + sample 175: + time = 2638666 + flags = 1 + data = length 213, hash B3FCC53E + sample 176: + time = 2660000 + flags = 1 + data = length 204, hash D70DD5A2 + sample 177: + time = 2681333 + flags = 1 + data = length 212, hash A4EF1B69 + sample 178: + time = 2702666 + flags = 1 + data = length 203, hash 8B0748B5 + sample 179: + time = 2724000 + flags = 1 + data = length 149, hash E455335B +tracksEnded = true From 1347a2200f1d0e07ddd1a6bf01dc575903e6688a Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 4 Jun 2020 13:15:51 +0100 Subject: [PATCH 0416/1052] Fix more cases of downloads not being resumed Issue: #7453 PiperOrigin-RevId: 314710328 --- RELEASENOTES.md | 5 +- .../exoplayer2/scheduler/Requirements.java | 25 +++++----- .../scheduler/RequirementsWatcher.java | 48 ++++++++++++++++--- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3323402192..78a7e7233b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,6 +1,6 @@ # Release notes # -### 2.11.5 (2020-06-03) ### +### 2.11.5 (2020-06-04) ### * Improve the smoothness of video playback immediately after starting, seeking or resuming a playback @@ -17,6 +17,9 @@ * Fix issue in `AudioTrackPositionTracker` that could cause negative positions to be reported at the start of playback and immediately after seeking ([#7456](https://github.com/google/ExoPlayer/issues/7456). +* Fix further cases where downloads would sometimes not resume after their + network requirements are met + ([#7453](https://github.com/google/ExoPlayer/issues/7453). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 8919a26720..f4183897eb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -129,11 +129,9 @@ public final class Requirements implements Parcelable { } ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo networkInfo = Assertions.checkNotNull(connectivityManager).getActiveNetworkInfo(); - if (networkInfo == null - || !networkInfo.isConnected() - || !isInternetConnectivityValidated(connectivityManager)) { + (ConnectivityManager) + Assertions.checkNotNull(context.getSystemService(Context.CONNECTIVITY_SERVICE)); + if (!isInternetConnectivityValidated(connectivityManager)) { return requirements & (NETWORK | NETWORK_UNMETERED); } @@ -156,23 +154,28 @@ public final class Requirements implements Parcelable { } private boolean isDeviceIdle(Context context) { - PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + PowerManager powerManager = + (PowerManager) Assertions.checkNotNull(context.getSystemService(Context.POWER_SERVICE)); return Util.SDK_INT >= 23 ? powerManager.isDeviceIdleMode() : Util.SDK_INT >= 20 ? !powerManager.isInteractive() : !powerManager.isScreenOn(); } private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { - // It's possible to query NetworkCapabilities from API level 23, but RequirementsWatcher only - // fires an event to update its Requirements when NetworkCapabilities change from API level 24. - // Since Requirements won't be updated, we assume connectivity is validated on API level 23. + // It's possible to check NetworkCapabilities.NET_CAPABILITY_VALIDATED from API level 23, but + // RequirementsWatcher only fires an event to re-check the requirements when NetworkCapabilities + // change from API level 24. We use the legacy path for API level 23 here to keep in sync. if (Util.SDK_INT < 24) { - return true; + // Legacy path. + @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); } - Network activeNetwork = connectivityManager.getActiveNetwork(); + + @Nullable Network activeNetwork = connectivityManager.getActiveNetwork(); if (activeNetwork == null) { return false; } + @Nullable NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork); return networkCapabilities != null diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index f55978c28a..80015cf3a7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -150,6 +150,23 @@ public final class RequirementsWatcher { } } + /** + * Re-checks the requirements if there are network requirements that are currently not met. + * + *

      When we receive an event that implies newly established network connectivity, we re-check + * the requirements by calling {@link #checkRequirements()}. This check sometimes sees that there + * is still no active network, meaning that any network requirements will remain not met. By + * calling this method when we receive other events that imply continued network connectivity, we + * can detect that the requirements are met once an active network does exist. + */ + private void recheckNotMetNetworkRequirements() { + if ((notMetRequirements & (Requirements.NETWORK | Requirements.NETWORK_UNMETERED)) == 0) { + // No unmet network requirements to recheck. + return; + } + checkRequirements(); + } + private class DeviceStatusChangeReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { @@ -161,17 +178,25 @@ public final class RequirementsWatcher { @RequiresApi(24) private final class NetworkCallback extends ConnectivityManager.NetworkCallback { - boolean receivedCapabilitiesChange; - boolean networkValidated; + + private boolean receivedCapabilitiesChange; + private boolean networkValidated; @Override public void onAvailable(Network network) { - onNetworkCallback(); + postCheckRequirements(); } @Override public void onLost(Network network) { - onNetworkCallback(); + postCheckRequirements(); + } + + @Override + public void onBlockedStatusChanged(Network network, boolean blocked) { + if (!blocked) { + postRecheckNotMetNetworkRequirements(); + } } @Override @@ -181,11 +206,13 @@ public final class RequirementsWatcher { if (!receivedCapabilitiesChange || this.networkValidated != networkValidated) { receivedCapabilitiesChange = true; this.networkValidated = networkValidated; - onNetworkCallback(); + postCheckRequirements(); + } else if (networkValidated) { + postRecheckNotMetNetworkRequirements(); } } - private void onNetworkCallback() { + private void postCheckRequirements() { handler.post( () -> { if (networkCallback != null) { @@ -193,5 +220,14 @@ public final class RequirementsWatcher { } }); } + + private void postRecheckNotMetNetworkRequirements() { + handler.post( + () -> { + if (networkCallback != null) { + recheckNotMetNetworkRequirements(); + } + }); + } } } From 12a93517814b6cdb44c4b4f71a20abce2f827ade Mon Sep 17 00:00:00 2001 From: kimvde Date: Thu, 4 Jun 2020 15:17:58 +0100 Subject: [PATCH 0417/1052] Miscellaneous renamings in FilenameUtils PiperOrigin-RevId: 314723830 --- .../android/exoplayer2/util/FileTypes.java | 159 ++++++++++++++++ .../android/exoplayer2/util/FilenameUtil.java | 177 ------------------ ...lenameUtilTest.java => FileTypesTest.java} | 20 +- .../extractor/DefaultExtractorsFactory.java | 74 +++----- .../hls/DefaultHlsExtractorFactory.java | 27 +-- 5 files changed, 207 insertions(+), 250 deletions(-) create mode 100644 library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java delete mode 100644 library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java rename library/common/src/test/java/com/google/android/exoplayer2/util/{FilenameUtilTest.java => FileTypesTest.java} (72%) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java new file mode 100644 index 0000000000..62fdb48e01 --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java @@ -0,0 +1,159 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import android.net.Uri; +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Defines common file type constants and helper methods. */ +public final class FileTypes { + + /** + * File types. One of {@link #UNKNOWN}, {@link #AC3}, {@link #AC4}, {@link #ADTS}, {@link #AMR}, + * {@link #FLAC}, {@link #FLV}, {@link #MATROSKA}, {@link #MP3}, {@link #MP4}, {@link #OGG}, + * {@link #PS}, {@link #TS}, {@link #WAV} and {@link #WEBVTT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNKNOWN, AC3, AC4, ADTS, AMR, FLAC, FLV, MATROSKA, MP3, MP4, OGG, PS, TS, WAV, WEBVTT}) + public @interface Type {} + /** Unknown file type. */ + public static final int UNKNOWN = -1; + /** File type for the AC-3 and E-AC-3 formats. */ + public static final int AC3 = 0; + /** File type for the AC-4 format. */ + public static final int AC4 = 1; + /** File type for the ADTS format. */ + public static final int ADTS = 2; + /** File type for the AMR format. */ + public static final int AMR = 3; + /** File type for the FLAC format. */ + public static final int FLAC = 4; + /** File type for the FLV format. */ + public static final int FLV = 5; + /** File type for the Matroska and WebM formats. */ + public static final int MATROSKA = 6; + /** File type for the MP3 format. */ + public static final int MP3 = 7; + /** File type for the MP4 format. */ + public static final int MP4 = 8; + /** File type for the Ogg format. */ + public static final int OGG = 9; + /** File type for the MPEG-PS format. */ + public static final int PS = 10; + /** File type for the MPEG-TS format. */ + public static final int TS = 11; + /** File type for the WAV format. */ + public static final int WAV = 12; + /** File type for the WebVTT format. */ + public static final int WEBVTT = 13; + + private static final String EXTENSION_AC3 = ".ac3"; + private static final String EXTENSION_EC3 = ".ec3"; + private static final String EXTENSION_AC4 = ".ac4"; + private static final String EXTENSION_ADTS = ".adts"; + private static final String EXTENSION_AAC = ".aac"; + private static final String EXTENSION_AMR = ".amr"; + private static final String EXTENSION_FLAC = ".flac"; + private static final String EXTENSION_FLV = ".flv"; + private static final String EXTENSION_PREFIX_MK = ".mk"; + private static final String EXTENSION_WEBM = ".webm"; + private static final String EXTENSION_PREFIX_OG = ".og"; + private static final String EXTENSION_OPUS = ".opus"; + private static final String EXTENSION_MP3 = ".mp3"; + private static final String EXTENSION_MP4 = ".mp4"; + private static final String EXTENSION_PREFIX_M4 = ".m4"; + private static final String EXTENSION_PREFIX_MP4 = ".mp4"; + private static final String EXTENSION_PREFIX_CMF = ".cmf"; + private static final String EXTENSION_PS = ".ps"; + private static final String EXTENSION_MPEG = ".mpeg"; + private static final String EXTENSION_MPG = ".mpg"; + private static final String EXTENSION_M2P = ".m2p"; + private static final String EXTENSION_TS = ".ts"; + private static final String EXTENSION_PREFIX_TS = ".ts"; + private static final String EXTENSION_WAV = ".wav"; + private static final String EXTENSION_WAVE = ".wave"; + private static final String EXTENSION_VTT = ".vtt"; + private static final String EXTENSION_WEBVTT = ".webvtt"; + + private FileTypes() {} + + /** + * Returns the {@link Type} corresponding to the filename extension of the provided {@link Uri}. + * The filename is considered to be the last segment of the {@link Uri} path. + */ + @FileTypes.Type + public static int getFormatFromExtension(Uri uri) { + String filename = uri.getLastPathSegment(); + if (filename == null) { + return FileTypes.UNKNOWN; + } else if (filename.endsWith(EXTENSION_AC3) || filename.endsWith(EXTENSION_EC3)) { + return FileTypes.AC3; + } else if (filename.endsWith(EXTENSION_AC4)) { + return FileTypes.AC4; + } else if (filename.endsWith(EXTENSION_ADTS) || filename.endsWith(EXTENSION_AAC)) { + return FileTypes.ADTS; + } else if (filename.endsWith(EXTENSION_AMR)) { + return FileTypes.AMR; + } else if (filename.endsWith(EXTENSION_FLAC)) { + return FileTypes.FLAC; + } else if (filename.endsWith(EXTENSION_FLV)) { + return FileTypes.FLV; + } else if (filename.startsWith( + EXTENSION_PREFIX_MK, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_MK.length() + 1)) + || filename.endsWith(EXTENSION_WEBM)) { + return FileTypes.MATROSKA; + } else if (filename.endsWith(EXTENSION_MP3)) { + return FileTypes.MP3; + } else if (filename.endsWith(EXTENSION_MP4) + || filename.startsWith( + EXTENSION_PREFIX_M4, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_M4.length() + 1)) + || filename.startsWith( + EXTENSION_PREFIX_MP4, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_MP4.length() + 1)) + || filename.startsWith( + EXTENSION_PREFIX_CMF, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_CMF.length() + 1))) { + return FileTypes.MP4; + } else if (filename.startsWith( + EXTENSION_PREFIX_OG, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_OG.length() + 1)) + || filename.endsWith(EXTENSION_OPUS)) { + return FileTypes.OGG; + } else if (filename.endsWith(EXTENSION_PS) + || filename.endsWith(EXTENSION_MPEG) + || filename.endsWith(EXTENSION_MPG) + || filename.endsWith(EXTENSION_M2P)) { + return FileTypes.PS; + } else if (filename.endsWith(EXTENSION_TS) + || filename.startsWith( + EXTENSION_PREFIX_TS, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_TS.length() + 1))) { + return FileTypes.TS; + } else if (filename.endsWith(EXTENSION_WAV) || filename.endsWith(EXTENSION_WAVE)) { + return FileTypes.WAV; + } else if (filename.endsWith(EXTENSION_VTT) || filename.endsWith(EXTENSION_WEBVTT)) { + return FileTypes.WEBVTT; + } else { + return FileTypes.UNKNOWN; + } + } +} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java deleted file mode 100644 index 614f58e94b..0000000000 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/FilenameUtil.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.util; - -import android.net.Uri; -import androidx.annotation.IntDef; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -/** Filename related utility methods. */ -public final class FilenameUtil { - - /** - * File formats. One of {@link #FILE_FORMAT_UNKNOWN}, {@link #FILE_FORMAT_AC3}, {@link - * #FILE_FORMAT_AC4}, {@link #FILE_FORMAT_ADTS}, {@link #FILE_FORMAT_AMR}, {@link - * #FILE_FORMAT_FLAC}, {@link #FILE_FORMAT_FLV}, {@link #FILE_FORMAT_MATROSKA}, {@link - * #FILE_FORMAT_MP3}, {@link #FILE_FORMAT_MP4}, {@link #FILE_FORMAT_OGG}, {@link #FILE_FORMAT_PS}, - * {@link #FILE_FORMAT_TS}, {@link #FILE_FORMAT_WAV} and {@link #FILE_FORMAT_WEBVTT}. - */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - FILE_FORMAT_UNKNOWN, - FILE_FORMAT_AC3, - FILE_FORMAT_AC4, - FILE_FORMAT_ADTS, - FILE_FORMAT_AMR, - FILE_FORMAT_FLAC, - FILE_FORMAT_FLV, - FILE_FORMAT_MATROSKA, - FILE_FORMAT_MP3, - FILE_FORMAT_MP4, - FILE_FORMAT_OGG, - FILE_FORMAT_PS, - FILE_FORMAT_TS, - FILE_FORMAT_WAV, - FILE_FORMAT_WEBVTT - }) - public @interface FileFormat {} - /** Unknown file format. */ - public static final int FILE_FORMAT_UNKNOWN = -1; - /** File format for AC-3 and E-AC-3. */ - public static final int FILE_FORMAT_AC3 = 0; - /** File format for AC-4. */ - public static final int FILE_FORMAT_AC4 = 1; - /** File format for ADTS. */ - public static final int FILE_FORMAT_ADTS = 2; - /** File format for AMR. */ - public static final int FILE_FORMAT_AMR = 3; - /** File format for FLAC. */ - public static final int FILE_FORMAT_FLAC = 4; - /** File format for FLV. */ - public static final int FILE_FORMAT_FLV = 5; - /** File format for Matroska and WebM. */ - public static final int FILE_FORMAT_MATROSKA = 6; - /** File format for MP3. */ - public static final int FILE_FORMAT_MP3 = 7; - /** File format for MP4. */ - public static final int FILE_FORMAT_MP4 = 8; - /** File format for Ogg. */ - public static final int FILE_FORMAT_OGG = 9; - /** File format for MPEG-PS. */ - public static final int FILE_FORMAT_PS = 10; - /** File format for MPEG-TS. */ - public static final int FILE_FORMAT_TS = 11; - /** File format for WAV. */ - public static final int FILE_FORMAT_WAV = 12; - /** File format for WebVTT. */ - public static final int FILE_FORMAT_WEBVTT = 13; - - private static final String FILE_EXTENSION_AC3 = ".ac3"; - private static final String FILE_EXTENSION_EC3 = ".ec3"; - private static final String FILE_EXTENSION_AC4 = ".ac4"; - private static final String FILE_EXTENSION_ADTS = ".adts"; - private static final String FILE_EXTENSION_AAC = ".aac"; - private static final String FILE_EXTENSION_AMR = ".amr"; - private static final String FILE_EXTENSION_FLAC = ".flac"; - private static final String FILE_EXTENSION_FLV = ".flv"; - private static final String FILE_EXTENSION_PREFIX_MK = ".mk"; - private static final String FILE_EXTENSION_WEBM = ".webm"; - private static final String FILE_EXTENSION_PREFIX_OG = ".og"; - private static final String FILE_EXTENSION_OPUS = ".opus"; - private static final String FILE_EXTENSION_MP3 = ".mp3"; - private static final String FILE_EXTENSION_MP4 = ".mp4"; - private static final String FILE_EXTENSION_PREFIX_M4 = ".m4"; - private static final String FILE_EXTENSION_PREFIX_MP4 = ".mp4"; - private static final String FILE_EXTENSION_PREFIX_CMF = ".cmf"; - private static final String FILE_EXTENSION_PS = ".ps"; - private static final String FILE_EXTENSION_MPEG = ".mpeg"; - private static final String FILE_EXTENSION_MPG = ".mpg"; - private static final String FILE_EXTENSION_M2P = ".m2p"; - private static final String FILE_EXTENSION_TS = ".ts"; - private static final String FILE_EXTENSION_PREFIX_TS = ".ts"; - private static final String FILE_EXTENSION_WAV = ".wav"; - private static final String FILE_EXTENSION_WAVE = ".wave"; - private static final String FILE_EXTENSION_VTT = ".vtt"; - private static final String FILE_EXTENSION_WEBVTT = ".webvtt"; - - private FilenameUtil() {} - - /** - * Returns the {@link FileFormat} corresponding to the filename extension of the provided {@link - * Uri}. The filename is considered to be the last segment of the {@link Uri} path. - */ - @FileFormat - public static int getFormatFromExtension(Uri uri) { - String filename = uri.getLastPathSegment(); - if (filename == null) { - return FILE_FORMAT_UNKNOWN; - } else if (filename.endsWith(FILE_EXTENSION_AC3) || filename.endsWith(FILE_EXTENSION_EC3)) { - return FILE_FORMAT_AC3; - } else if (filename.endsWith(FILE_EXTENSION_AC4)) { - return FILE_FORMAT_AC4; - } else if (filename.endsWith(FILE_EXTENSION_ADTS) || filename.endsWith(FILE_EXTENSION_AAC)) { - return FILE_FORMAT_ADTS; - } else if (filename.endsWith(FILE_EXTENSION_AMR)) { - return FILE_FORMAT_AMR; - } else if (filename.endsWith(FILE_EXTENSION_FLAC)) { - return FILE_FORMAT_FLAC; - } else if (filename.endsWith(FILE_EXTENSION_FLV)) { - return FILE_FORMAT_FLV; - } else if (filename.startsWith( - FILE_EXTENSION_PREFIX_MK, - /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_MK.length() + 1)) - || filename.endsWith(FILE_EXTENSION_WEBM)) { - return FILE_FORMAT_MATROSKA; - } else if (filename.endsWith(FILE_EXTENSION_MP3)) { - return FILE_FORMAT_MP3; - } else if (filename.endsWith(FILE_EXTENSION_MP4) - || filename.startsWith( - FILE_EXTENSION_PREFIX_M4, - /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_M4.length() + 1)) - || filename.startsWith( - FILE_EXTENSION_PREFIX_MP4, - /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_MP4.length() + 1)) - || filename.startsWith( - FILE_EXTENSION_PREFIX_CMF, - /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_CMF.length() + 1))) { - return FILE_FORMAT_MP4; - } else if (filename.startsWith( - FILE_EXTENSION_PREFIX_OG, - /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_OG.length() + 1)) - || filename.endsWith(FILE_EXTENSION_OPUS)) { - return FILE_FORMAT_OGG; - } else if (filename.endsWith(FILE_EXTENSION_PS) - || filename.endsWith(FILE_EXTENSION_MPEG) - || filename.endsWith(FILE_EXTENSION_MPG) - || filename.endsWith(FILE_EXTENSION_M2P)) { - return FILE_FORMAT_PS; - } else if (filename.endsWith(FILE_EXTENSION_TS) - || filename.startsWith( - FILE_EXTENSION_PREFIX_TS, - /* toffset= */ filename.length() - (FILE_EXTENSION_PREFIX_TS.length() + 1))) { - return FILE_FORMAT_TS; - } else if (filename.endsWith(FILE_EXTENSION_WAV) || filename.endsWith(FILE_EXTENSION_WAVE)) { - return FILE_FORMAT_WAV; - } else if (filename.endsWith(FILE_EXTENSION_VTT) || filename.endsWith(FILE_EXTENSION_WEBVTT)) { - return FILE_FORMAT_WEBVTT; - } else { - return FILE_FORMAT_UNKNOWN; - } - } -} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java similarity index 72% rename from library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java rename to library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java index d3849356f3..ed7c17055d 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/FilenameUtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java @@ -15,10 +15,7 @@ */ package com.google.android.exoplayer2.util; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MATROSKA; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP3; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_UNKNOWN; -import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; +import static com.google.android.exoplayer2.util.FileTypes.getFormatFromExtension; import static com.google.common.truth.Truth.assertThat; import android.net.Uri; @@ -26,24 +23,23 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; -/** Tests for {@link FilenameUtilTest}. */ +/** Tests for {@link FileTypesTest}. */ @RunWith(AndroidJUnit4.class) -public class FilenameUtilTest { +public class FileTypesTest { @Test public void getFormatFromExtension_withExtension_returnsExpectedFormat() { - assertThat(getFormatFromExtension(Uri.parse("filename.mp3"))).isEqualTo(FILE_FORMAT_MP3); + assertThat(getFormatFromExtension(Uri.parse("filename.mp3"))).isEqualTo(FileTypes.MP3); } @Test public void getFormatFromExtension_withExtensionPrefix_returnsExpectedFormat() { - assertThat(getFormatFromExtension(Uri.parse("filename.mka"))).isEqualTo(FILE_FORMAT_MATROSKA); + assertThat(getFormatFromExtension(Uri.parse("filename.mka"))).isEqualTo(FileTypes.MATROSKA); } @Test public void getFormatFromExtension_withUnknownExtension_returnsUnknownFormat() { - assertThat(getFormatFromExtension(Uri.parse("filename.unknown"))) - .isEqualTo(FILE_FORMAT_UNKNOWN); + assertThat(getFormatFromExtension(Uri.parse("filename.unknown"))).isEqualTo(FileTypes.UNKNOWN); } @Test @@ -51,11 +47,11 @@ public class FilenameUtilTest { assertThat( getFormatFromExtension( Uri.parse("http://www.example.com/filename.mp3?query=myquery#fragment"))) - .isEqualTo(FILE_FORMAT_MP3); + .isEqualTo(FileTypes.MP3); } @Test public void getFormatFromExtension_withNullFilename_returnsUnknownFormat() { - assertThat(getFormatFromExtension(Uri.EMPTY)).isEqualTo(FILE_FORMAT_UNKNOWN); + assertThat(getFormatFromExtension(Uri.EMPTY)).isEqualTo(FileTypes.UNKNOWN); } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 431b96a5ea..3e329573ba 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -15,20 +15,7 @@ */ package com.google.android.exoplayer2.extractor; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC3; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC4; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_ADTS; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AMR; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_FLAC; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_FLV; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MATROSKA; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP3; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP4; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_OGG; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_PS; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_TS; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_WAV; -import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; +import static com.google.android.exoplayer2.util.FileTypes.getFormatFromExtension; import android.net.Uri; import androidx.annotation.Nullable; @@ -48,7 +35,7 @@ import com.google.android.exoplayer2.extractor.ts.PsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.extractor.ts.TsPayloadReader; import com.google.android.exoplayer2.extractor.wav.WavExtractor; -import com.google.android.exoplayer2.util.FilenameUtil; +import com.google.android.exoplayer2.util.FileTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.lang.reflect.Constructor; import java.util.ArrayList; @@ -86,19 +73,19 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. private static final int[] DEFAULT_EXTRACTOR_ORDER = new int[] { - FILE_FORMAT_FLV, - FILE_FORMAT_FLAC, - FILE_FORMAT_WAV, - FILE_FORMAT_MP4, - FILE_FORMAT_AMR, - FILE_FORMAT_PS, - FILE_FORMAT_OGG, - FILE_FORMAT_TS, - FILE_FORMAT_MATROSKA, - FILE_FORMAT_ADTS, - FILE_FORMAT_AC3, - FILE_FORMAT_AC4, - FILE_FORMAT_MP3, + FileTypes.FLV, + FileTypes.FLAC, + FileTypes.WAV, + FileTypes.MP4, + FileTypes.AMR, + FileTypes.PS, + FileTypes.OGG, + FileTypes.TS, + FileTypes.MATROSKA, + FileTypes.ADTS, + FileTypes.AC3, + FileTypes.AC4, + FileTypes.MP3, }; @Nullable @@ -285,7 +272,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { public synchronized Extractor[] createExtractors(Uri uri) { List extractors = new ArrayList<>(/* initialCapacity= */ 14); - @FilenameUtil.FileFormat int extensionFormat = getFormatFromExtension(uri); + @FileTypes.Type int extensionFormat = getFormatFromExtension(uri); addExtractorsForFormat(extensionFormat, extractors); for (int format : DEFAULT_EXTRACTOR_ORDER) { @@ -297,16 +284,15 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { return extractors.toArray(new Extractor[extractors.size()]); } - private void addExtractorsForFormat( - @FilenameUtil.FileFormat int fileFormat, List extractors) { + private void addExtractorsForFormat(@FileTypes.Type int fileFormat, List extractors) { switch (fileFormat) { - case FILE_FORMAT_AC3: + case FileTypes.AC3: extractors.add(new Ac3Extractor()); break; - case FILE_FORMAT_AC4: + case FileTypes.AC4: extractors.add(new Ac4Extractor()); break; - case FILE_FORMAT_ADTS: + case FileTypes.ADTS: extractors.add( new AdtsExtractor( adtsFlags @@ -314,7 +300,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ? AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING : 0))); break; - case FILE_FORMAT_AMR: + case FileTypes.AMR: extractors.add( new AmrExtractor( amrFlags @@ -322,7 +308,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING : 0))); break; - case FILE_FORMAT_FLAC: + case FileTypes.FLAC: if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { try { extractors.add(FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance()); @@ -334,13 +320,13 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { extractors.add(new FlacExtractor(coreFlacFlags)); } break; - case FILE_FORMAT_FLV: + case FileTypes.FLV: extractors.add(new FlvExtractor()); break; - case FILE_FORMAT_MATROSKA: + case FileTypes.MATROSKA: extractors.add(new MatroskaExtractor(matroskaFlags)); break; - case FILE_FORMAT_MP3: + case FileTypes.MP3: extractors.add( new Mp3Extractor( mp3Flags @@ -348,20 +334,20 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ? Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING : 0))); break; - case FILE_FORMAT_MP4: + case FileTypes.MP4: extractors.add(new FragmentedMp4Extractor(fragmentedMp4Flags)); extractors.add(new Mp4Extractor(mp4Flags)); break; - case FILE_FORMAT_OGG: + case FileTypes.OGG: extractors.add(new OggExtractor()); break; - case FILE_FORMAT_PS: + case FileTypes.PS: extractors.add(new PsExtractor()); break; - case FILE_FORMAT_TS: + case FileTypes.TS: extractors.add(new TsExtractor(tsMode, tsFlags)); break; - case FILE_FORMAT_WAV: + case FileTypes.WAV: extractors.add(new WavExtractor()); break; default: diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 010b6e2417..32a156f66d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -15,14 +15,7 @@ */ package com.google.android.exoplayer2.source.hls; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC3; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_AC4; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_ADTS; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP3; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_MP4; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_TS; -import static com.google.android.exoplayer2.util.FilenameUtil.FILE_FORMAT_WEBVTT; -import static com.google.android.exoplayer2.util.FilenameUtil.getFormatFromExtension; +import static com.google.android.exoplayer2.util.FileTypes.getFormatFromExtension; import android.net.Uri; import android.text.TextUtils; @@ -39,7 +32,7 @@ import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.FilenameUtil; +import com.google.android.exoplayer2.util.FileTypes; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.EOFException; @@ -195,21 +188,21 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType)) { return new WebvttExtractor(format.language, timestampAdjuster); } - @FilenameUtil.FileFormat int fileFormat = getFormatFromExtension(uri); + @FileTypes.Type int fileFormat = getFormatFromExtension(uri); switch (fileFormat) { - case FILE_FORMAT_WEBVTT: + case FileTypes.WEBVTT: return new WebvttExtractor(format.language, timestampAdjuster); - case FILE_FORMAT_ADTS: + case FileTypes.ADTS: return new AdtsExtractor(); - case FILE_FORMAT_AC3: + case FileTypes.AC3: return new Ac3Extractor(); - case FILE_FORMAT_AC4: + case FileTypes.AC4: return new Ac4Extractor(); - case FILE_FORMAT_MP3: + case FileTypes.MP3: return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); - case FILE_FORMAT_MP4: + case FileTypes.MP4: return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); - case FILE_FORMAT_TS: + case FileTypes.TS: return createTsExtractor( payloadReaderFactoryFlags, exposeCea608WhenMissingDeclarations, From 1dedd080a4a9ed56260dff6f25d1985657be5718 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 4 Jun 2020 15:50:41 +0100 Subject: [PATCH 0418/1052] Remove unnecessary TargetApi This can hide errors as the lint is pretty smart to understand SDK level checks. PiperOrigin-RevId: 314728030 --- .../com/google/android/exoplayer2/drm/FrameworkMediaDrm.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index 2227738ed5..74881646a2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.media.DeniedByServerException; import android.media.MediaCryptoException; import android.media.MediaDrm; @@ -45,7 +44,6 @@ import java.util.Map; import java.util.UUID; /** An {@link ExoMediaDrm} implementation that wraps the framework {@link MediaDrm}. */ -@TargetApi(23) @RequiresApi(18) public final class FrameworkMediaDrm implements ExoMediaDrm { @@ -254,7 +252,6 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { @Override @Nullable - @TargetApi(28) public PersistableBundle getMetrics() { if (Util.SDK_INT < 28) { return null; From 08b0e08b694da95c63451b756347c8f961a896f7 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 4 Jun 2020 20:22:11 +0100 Subject: [PATCH 0419/1052] Fix bug where SilenceMediaSource#getTag was always returning null. SilenceMediaSource never overloaded MediaSource#getTag, and default behavior is to return null. PiperOrigin-RevId: 314779832 --- RELEASENOTES.md | 1 + .../android/exoplayer2/source/SilenceMediaSource.java | 6 ++++++ .../exoplayer2/source/SilenceMediaSourceTest.java | 10 ++++++++++ 3 files changed, 17 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5017ab3ec1..5632c84157 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -3,6 +3,7 @@ ### dev-v2 (not yet released) * Core library: + * Implement getTag for SilenceMediaSource. * Add `Player.getTrackSelector` to access track selector from UI module. * Added `TextComponent.getCurrentCues` because the current cues are no longer forwarded to a new `TextOutput` in `SimpleExoPlayer` diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index 2753cfe8f5..a7669ae345 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -151,6 +151,12 @@ public final class SilenceMediaSource extends BaseMediaSource { return mediaItem; } + @Nullable + @Override + public Object getTag() { + return Assertions.checkNotNull(mediaItem.playbackProperties).tag; + } + @Override protected void releaseSourceInternal() {} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java index 66a1da0f00..d8a7727953 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java @@ -52,6 +52,16 @@ public class SilenceMediaSourceTest { assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } + @Test + public void builderSetTag_setsTagOfMediaSource() { + Object tag = new Object(); + + SilenceMediaSource mediaSource = + new SilenceMediaSource.Factory().setTag(tag).setDurationUs(1_000_000).createMediaSource(); + + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); + } + @Test public void builder_setDurationUsNotCalled_throwsIllegalStateException() { assertThrows(IllegalStateException.class, new SilenceMediaSource.Factory()::createMediaSource); From 60f907be6dd68b4151668295a66c4b24169ef4b6 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 5 Jun 2020 11:14:18 +0100 Subject: [PATCH 0420/1052] Make FakeTimeline and FakeMediaSource provide a media item FakeMediaSource and FakeTimeline should put a media item to the window just as other media sources and timelines do. This change provides a fake media item for both of them. Further the MaskingMediaSource needs to provide a media item for when the real timeline of the masked media source is not available. This can be easily done by using mediaSource.getMediaItem() once available. For now a dummy is used to make ExoPlayerTest run green. This can be easily change to use mediaSource.getMediaSource as soon as this method is defined by the MediaSource interface. PiperOrigin-RevId: 314897474 --- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 3 ++- .../exoplayer2/source/MaskingMediaSource.java | 20 +++++++++------ .../android/exoplayer2/ExoPlayerTest.java | 25 +++++++++++++------ .../source/ClippingMediaSourceTest.java | 4 ++- .../exoplayer2/testutil/FakeMediaSource.java | 22 +++++++++++++--- .../exoplayer2/testutil/FakeTimeline.java | 8 +++++- 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 5e7508dc7e..27bb641d4f 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -44,6 +44,7 @@ import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; @@ -160,7 +161,7 @@ public final class ImaAdsLoaderTest { @Test public void start_withPlaceholderContent_initializedAdsLoader() { - Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); + Timeline placeholderTimeline = new DummyTimeline(MediaItem.fromUri(Uri.EMPTY)); setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index 35b3e1848e..db8d7e85bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -15,10 +15,12 @@ */ package com.google.android.exoplayer2.source; +import android.net.Uri; import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; @@ -66,7 +68,10 @@ public final class MaskingMediaSource extends CompositeMediaSource { initialTimeline, /* firstWindowUid= */ null, /* firstPeriodUid= */ null); hasRealTimeline = true; } else { - timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getTag()); + // TODO(bachinger) Use mediasSource.getMediaItem() to provide the media item. + timeline = + MaskingTimeline.createWithDummyTimeline( + new MediaItem.Builder().setUri(Uri.EMPTY).setTag(mediaSource.getTag()).build()); } } @@ -268,9 +273,9 @@ public final class MaskingMediaSource extends CompositeMediaSource { * * @param windowTag A window tag. */ - public static MaskingTimeline createWithDummyTimeline(@Nullable Object windowTag) { + public static MaskingTimeline createWithDummyTimeline(MediaItem mediaItem) { return new MaskingTimeline( - new DummyTimeline(windowTag), Window.SINGLE_WINDOW_UID, DUMMY_EXTERNAL_PERIOD_UID); + new DummyTimeline(mediaItem), Window.SINGLE_WINDOW_UID, DUMMY_EXTERNAL_PERIOD_UID); } /** @@ -348,10 +353,11 @@ public final class MaskingMediaSource extends CompositeMediaSource { @VisibleForTesting public static final class DummyTimeline extends Timeline { - @Nullable private final Object tag; + private final MediaItem mediaItem; - public DummyTimeline(@Nullable Object tag) { - this.tag = tag; + /** Creates a new instance with the given media item. */ + public DummyTimeline(MediaItem mediaItem) { + this.mediaItem = mediaItem; } @Override @@ -363,7 +369,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { window.set( Window.SINGLE_WINDOW_UID, - tag, + mediaItem, /* manifest= */ null, /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 788852ed98..5fc65a678b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -126,7 +126,9 @@ public final class ExoPlayerTest { @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); - dummyTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ 0); + dummyTimeline = + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(0).build()); } /** @@ -136,7 +138,8 @@ public final class ExoPlayerTest { @Test public void playEmptyTimeline() throws Exception { Timeline timeline = Timeline.EMPTY; - Timeline expectedMaskingTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); + Timeline expectedMaskingTimeline = + new MaskingMediaSource.DummyTimeline(FakeTimeline.FAKE_MEDIA_ITEM); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_UNKNOWN); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) @@ -1226,13 +1229,15 @@ public final class ExoPlayerTest { new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); Timeline firstExpectedMaskingTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(firstWindowId).build()); Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); Timeline secondExpectedMaskingTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(secondWindowId).build()); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = @@ -1278,13 +1283,15 @@ public final class ExoPlayerTest { new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); Timeline firstExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(firstWindowId).build()); Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); Timeline secondExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(secondWindowId).build()); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = @@ -1330,13 +1337,15 @@ public final class ExoPlayerTest { new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); Timeline firstExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(firstWindowId).build()); Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); Timeline secondExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + new MaskingMediaSource.DummyTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(secondWindowId).build()); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index 5d47eec430..d63531597e 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -18,9 +18,11 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import android.net.Uri; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; @@ -171,7 +173,7 @@ public final class ClippingMediaSourceTest { // Timeline that's dynamic and not seekable. A child source might report such a timeline prior // to it having loaded sufficient data to establish its duration and seekability. Such timelines // should not result in clipping failure. - Timeline timeline = new DummyTimeline(/* tag= */ null); + Timeline timeline = new DummyTimeline(MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline( diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index 70b67e9fc1..1027d7d620 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -15,14 +15,15 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.android.exoplayer2.util.Util.castNonNull; import static com.google.common.truth.Truth.assertThat; -import android.net.Uri; import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.drm.DrmSessionManager; @@ -68,7 +69,12 @@ public class FakeMediaSource extends BaseMediaSource { } } - private static final DataSpec FAKE_DATA_SPEC = new DataSpec(Uri.parse("http://manifest.uri")); + /** The media item used by the fake media source. */ + public static final MediaItem FAKE_MEDIA_ITEM = + new MediaItem.Builder().setUri("http://manifest.uri").build(); + + private static final DataSpec FAKE_DATA_SPEC = + new DataSpec(castNonNull(FAKE_MEDIA_ITEM.playbackProperties).uri); private static final int MANIFEST_LOAD_BYTES = 100; private final TrackGroupArray trackGroupArray; @@ -137,6 +143,14 @@ public class FakeMediaSource extends BaseMediaSource { return timeline.getWindow(0, new Timeline.Window()).tag; } + // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + public MediaItem getMediaItem() { + if (timeline == null || timeline.isEmpty()) { + return FAKE_MEDIA_ITEM; + } + return timeline.getWindow(0, new Timeline.Window()).mediaItem; + } + @Override @Nullable public Timeline getInitialTimeline() { @@ -172,7 +186,7 @@ public class FakeMediaSource extends BaseMediaSource { public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { assertThat(preparedSource).isTrue(); assertThat(releasedSource).isFalse(); - int periodIndex = Util.castNonNull(timeline).getIndexOfPeriod(id.periodUid); + int periodIndex = castNonNull(timeline).getIndexOfPeriod(id.periodUid); Assertions.checkArgument(periodIndex != C.INDEX_UNSET); Period period = timeline.getPeriod(periodIndex, new Period()); EventDispatcher eventDispatcher = @@ -202,7 +216,7 @@ public class FakeMediaSource extends BaseMediaSource { drmSessionManager.release(); releasedSource = true; preparedSource = false; - Util.castNonNull(sourceInfoRefreshHandler).removeCallbacksAndMessages(null); + castNonNull(sourceInfoRefreshHandler).removeCallbacksAndMessages(null); sourceInfoRefreshHandler = null; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 19d172d0be..81d8844372 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.testutil; import android.net.Uri; import android.util.Pair; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.util.Assertions; @@ -41,6 +42,7 @@ public final class FakeTimeline extends Timeline { public final int periodCount; public final Object id; + public final MediaItem mediaItem; public final boolean isSeekable; public final boolean isDynamic; public final boolean isLive; @@ -169,6 +171,7 @@ public final class FakeTimeline extends Timeline { Assertions.checkArgument(durationUs != C.TIME_UNSET || periodCount == 1); this.periodCount = periodCount; this.id = id; + this.mediaItem = FAKE_MEDIA_ITEM.buildUpon().setTag(id).build(); this.isSeekable = isSeekable; this.isDynamic = isDynamic; this.isLive = isLive; @@ -180,6 +183,9 @@ public final class FakeTimeline extends Timeline { } } + /** The fake media item used by the fake timeline. */ + public static final MediaItem FAKE_MEDIA_ITEM = new MediaItem.Builder().setUri(Uri.EMPTY).build(); + private static final long AD_DURATION_US = 10 * C.MICROS_PER_SECOND; private final TimelineWindowDefinition[] windowDefinitions; @@ -262,7 +268,7 @@ public final class FakeTimeline extends Timeline { TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; window.set( /* uid= */ windowDefinition.id, - /* tag= */ windowDefinition.id, + windowDefinition.mediaItem, manifests[windowIndex], /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, From b874b1d563aa867b896c74bab1da6c4e1a7512d2 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 5 Jun 2020 12:15:54 +0100 Subject: [PATCH 0421/1052] Bump version to 2.11.5 PiperOrigin-RevId: 314903986 --- RELEASENOTES.md | 2 +- constants.gradle | 4 ++-- .../com/google/android/exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5632c84157..fc0eacab2e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -189,7 +189,7 @@ * Demo app: Retain previous position in list of samples. * Add Guava dependency. -### 2.11.5 (2020-06-04) ### +### 2.11.5 (2020-06-05) ### * Improve the smoothness of video playback immediately after starting, seeking or resuming a playback diff --git a/constants.gradle b/constants.gradle index 852da69b07..d08fd38ec1 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.4' - releaseVersionCode = 2011004 + releaseVersion = '2.11.5' + releaseVersionCode = 2011005 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved. Also fix TODOs in UtilTest. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 06743732e7..15d43c7b79 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.4"; + public static final String VERSION = "2.11.5"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.4"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.5"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011004; + public static final int VERSION_INT = 2011005; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From 97a80ac624af0b645be8f8bb83293b81a29aa15e Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 5 Jun 2020 12:25:27 +0100 Subject: [PATCH 0422/1052] Make ConcatentatingMediaSource provide a dummy media item PiperOrigin-RevId: 314904897 --- .../source/ConcatenatingMediaSource.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 8664c4367b..8268798ee3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -15,12 +15,14 @@ */ package com.google.android.exoplayer2.source; +import android.net.Uri; import android.os.Handler; import android.os.Message; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import com.google.android.exoplayer2.AbstractConcatenatedTimeline; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder; import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; @@ -54,6 +56,9 @@ public final class ConcatenatingMediaSource extends CompositeMediaSource mediaSourcesPublic; @@ -437,10 +442,11 @@ public final class ConcatenatingMediaSource extends CompositeMediaSource Date: Fri, 5 Jun 2020 12:48:52 +0100 Subject: [PATCH 0423/1052] Introduce an offload option to DefaultRederersFactory This introduces an option to turn on offload in the audio sink. #exo-offload PiperOrigin-RevId: 314907088 --- .../exoplayer2/DefaultRenderersFactory.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index a09f85d42f..0b74d1175c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.DefaultAudioSink; +import com.google.android.exoplayer2.audio.DefaultAudioSink.DefaultAudioProcessorChain; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; @@ -90,6 +91,7 @@ public class DefaultRenderersFactory implements RenderersFactory { private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; private @MediaCodecRenderer.MediaCodecOperationMode int mediaCodecOperationMode; + private boolean enableOffload; /** @param context A {@link Context}. */ public DefaultRenderersFactory(Context context) { @@ -183,6 +185,20 @@ public class DefaultRenderersFactory implements RenderersFactory { return this; } + /** + * Sets whether audio should be played using the offload path. Audio offload disables audio + * processors (for example speed adjustment). + * + *

      The default value is {@code false}. + * + * @param enableOffload If audio offload should be used. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { + this.enableOffload = enableOffload; + return this; + } + /** * Sets the maximum duration for which video renderers can attempt to seamlessly join an ongoing * playback. @@ -223,6 +239,7 @@ public class DefaultRenderersFactory implements RenderersFactory { buildAudioProcessors(), eventHandler, audioRendererEventListener, + enableOffload, renderersList); buildTextRenderers(context, textRendererOutput, eventHandler.getLooper(), extensionRendererMode, renderersList); @@ -373,6 +390,7 @@ public class DefaultRenderersFactory implements RenderersFactory { * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. + * @param enableOffload If the renderer should use audio offload for all supported formats. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( @@ -383,6 +401,7 @@ public class DefaultRenderersFactory implements RenderersFactory { AudioProcessor[] audioProcessors, Handler eventHandler, AudioRendererEventListener eventListener, + boolean enableOffload, ArrayList out) { MediaCodecAudioRenderer audioRenderer = new MediaCodecAudioRenderer( @@ -391,7 +410,11 @@ public class DefaultRenderersFactory implements RenderersFactory { enableDecoderFallback, eventHandler, eventListener, - new DefaultAudioSink(AudioCapabilities.getCapabilities(context), audioProcessors)); + new DefaultAudioSink( + AudioCapabilities.getCapabilities(context), + new DefaultAudioProcessorChain(audioProcessors), + /* enableFloatOutput= */ false, + enableOffload)); audioRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); out.add(audioRenderer); From 7a8f878a1f6de82470cb28a91eb79f3a50a69920 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 5 Jun 2020 12:15:54 +0100 Subject: [PATCH 0424/1052] Bump version to 2.11.5 PiperOrigin-RevId: 314903986 --- RELEASENOTES.md | 2 +- constants.gradle | 4 ++-- .../com/google/android/exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 78a7e7233b..9055d86943 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,6 +1,6 @@ # Release notes # -### 2.11.5 (2020-06-04) ### +### 2.11.5 (2020-06-05) ### * Improve the smoothness of video playback immediately after starting, seeking or resuming a playback diff --git a/constants.gradle b/constants.gradle index c79130cacb..1d7a0f0ebd 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.4' - releaseVersionCode = 2011004 + releaseVersion = '2.11.5' + releaseVersionCode = 2011005 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 06743732e7..15d43c7b79 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.4"; + public static final String VERSION = "2.11.5"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.4"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.5"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011004; + public static final int VERSION_INT = 2011005; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From cdb40257581c1a87b0fc6bac57a0bd3d86866605 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 5 Jun 2020 13:14:55 +0100 Subject: [PATCH 0425/1052] Revert "Update Gradle plugins." This reverts commit c20b85ac60701a34081d8d7488ae434b9792aa9b. --- build.gradle | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index d520925fb0..a4823b94ee 100644 --- a/build.gradle +++ b/build.gradle @@ -17,9 +17,9 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.6.3' + classpath 'com.android.tools.build:gradle:3.5.1' classpath 'com.novoda:bintray-release:0.9.1' - classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.1' + classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.0' } } allprojects { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dc65d6734f..7fefd1c665 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip From ee0c6224af554bd076fcc91bc7d7b7d1cf7853d5 Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 5 Jun 2020 14:42:47 +0100 Subject: [PATCH 0426/1052] Respect 33-bit wraparound when calculating WebVTT timestamps in HLS Issue: #7462 PiperOrigin-RevId: 314919210 --- RELEASENOTES.md | 2 ++ .../exoplayer2/util/TimestampAdjuster.java | 19 ++++++++++++++++--- .../scte35/SpliceInfoDecoderTest.java | 3 ++- .../source/hls/WebvttExtractor.java | 5 +++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fc0eacab2e..ce8541ed1e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -157,6 +157,8 @@ * HLS: * Add support for upstream discard including cancelation of ongoing load ([#6322](https://github.com/google/ExoPlayer/issues/6322)). + * Respect 33-bit PTS wrapping when applying `X-TIMESTAMP-MAP` to WebVTT + timestamps ([#7464](https://github.com/google/ExoPlayer/issues/7464)). * Ogg: Allow non-contiguous pages ([#7230](https://github.com/google/ExoPlayer/issues/7230)). * Extractors: diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java b/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java index 439374a086..65f88b1983 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java @@ -113,7 +113,7 @@ public final class TimestampAdjuster { if (lastSampleTimestampUs != C.TIME_UNSET) { // The wrap count for the current PTS may be closestWrapCount or (closestWrapCount - 1), // and we need to snap to the one closest to lastSampleTimestampUs. - long lastPts = usToPts(lastSampleTimestampUs); + long lastPts = usToNonWrappedPts(lastSampleTimestampUs); long closestWrapCount = (lastPts + (MAX_PTS_PLUS_ONE / 2)) / MAX_PTS_PLUS_ONE; long ptsWrapBelow = pts90Khz + (MAX_PTS_PLUS_ONE * (closestWrapCount - 1)); long ptsWrapAbove = pts90Khz + (MAX_PTS_PLUS_ONE * closestWrapCount); @@ -173,14 +173,27 @@ public final class TimestampAdjuster { return (pts * C.MICROS_PER_SECOND) / 90000; } + /** + * Converts a timestamp in microseconds to a 90 kHz clock timestamp, performing wraparound to keep + * the result within 33-bits. + * + * @param us A value in microseconds. + * @return The corresponding value as a 90 kHz clock timestamp, wrapped to 33 bits. + */ + public static long usToWrappedPts(long us) { + return usToNonWrappedPts(us) % MAX_PTS_PLUS_ONE; + } + /** * Converts a timestamp in microseconds to a 90 kHz clock timestamp. * + *

      Does not perform any wraparound. To get a 90 kHz timestamp suitable for use with MPEG-TS, + * use {@link #usToWrappedPts(long)}. + * * @param us A value in microseconds. * @return The corresponding value as a 90 kHz clock timestamp. */ - public static long usToPts(long us) { + public static long usToNonWrappedPts(long us) { return (us * 90000) / C.MICROS_PER_SECOND; } - } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java index 90c2e7d386..dcb1f634c9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java @@ -200,7 +200,8 @@ public final class SpliceInfoDecoderTest { } private static long removePtsConversionPrecisionError(long timeUs, long offsetUs) { - return TimestampAdjuster.ptsToUs(TimestampAdjuster.usToPts(timeUs - offsetUs)) + offsetUs; + return TimestampAdjuster.ptsToUs(TimestampAdjuster.usToNonWrappedPts(timeUs - offsetUs)) + + offsetUs; } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java index 6a390001d2..832de00cf9 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java @@ -176,8 +176,9 @@ public final class WebvttExtractor implements Extractor { long firstCueTimeUs = WebvttParserUtil.parseTimestampUs(Assertions.checkNotNull(cueHeaderMatcher.group(1))); - long sampleTimeUs = timestampAdjuster.adjustTsTimestamp( - TimestampAdjuster.usToPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); + long sampleTimeUs = + timestampAdjuster.adjustTsTimestamp( + TimestampAdjuster.usToWrappedPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); long subsampleOffsetUs = sampleTimeUs - firstCueTimeUs; // Output the track. TrackOutput trackOutput = buildTrackOutput(subsampleOffsetUs); From 226583f01c5dd42453bb2fa9fa38344cf10196d0 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 5 Jun 2020 15:37:49 +0100 Subject: [PATCH 0427/1052] Reintroduce isConnected check for download requirements PiperOrigin-RevId: 314925639 --- .../android/exoplayer2/scheduler/Requirements.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 8cee69ebcc..7a2946d012 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -156,7 +156,10 @@ public final class Requirements implements Parcelable { ConnectivityManager connectivityManager = (ConnectivityManager) Assertions.checkNotNull(context.getSystemService(Context.CONNECTIVITY_SERVICE)); - if (!isInternetConnectivityValidated(connectivityManager)) { + @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + if (networkInfo == null + || !networkInfo.isConnected() + || !isInternetConnectivityValidated(connectivityManager)) { return requirements & (NETWORK | NETWORK_UNMETERED); } @@ -197,11 +200,10 @@ public final class Requirements implements Parcelable { private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { // It's possible to check NetworkCapabilities.NET_CAPABILITY_VALIDATED from API level 23, but // RequirementsWatcher only fires an event to re-check the requirements when NetworkCapabilities - // change from API level 24. We use the legacy path for API level 23 here to keep in sync. + // change from API level 24. We assume that network capability is validated for API level 23 to + // keep in sync. if (Util.SDK_INT < 24) { - // Legacy path. - @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); - return networkInfo != null && networkInfo.isConnected(); + return true; } @Nullable Network activeNetwork = connectivityManager.getActiveNetwork(); From 46d29b25c9bec032440d545de1494f18815cc00a Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 5 Jun 2020 15:37:49 +0100 Subject: [PATCH 0428/1052] Reintroduce isConnected check for download requirements PiperOrigin-RevId: 314925639 --- .../android/exoplayer2/scheduler/Requirements.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index f4183897eb..4e2c83d5d6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -131,7 +131,10 @@ public final class Requirements implements Parcelable { ConnectivityManager connectivityManager = (ConnectivityManager) Assertions.checkNotNull(context.getSystemService(Context.CONNECTIVITY_SERVICE)); - if (!isInternetConnectivityValidated(connectivityManager)) { + @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + if (networkInfo == null + || !networkInfo.isConnected() + || !isInternetConnectivityValidated(connectivityManager)) { return requirements & (NETWORK | NETWORK_UNMETERED); } @@ -164,11 +167,10 @@ public final class Requirements implements Parcelable { private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { // It's possible to check NetworkCapabilities.NET_CAPABILITY_VALIDATED from API level 23, but // RequirementsWatcher only fires an event to re-check the requirements when NetworkCapabilities - // change from API level 24. We use the legacy path for API level 23 here to keep in sync. + // change from API level 24. We assume that network capability is validated for API level 23 to + // keep in sync. if (Util.SDK_INT < 24) { - // Legacy path. - @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); - return networkInfo != null && networkInfo.isConnected(); + return true; } @Nullable Network activeNetwork = connectivityManager.getActiveNetwork(); From c3282c9a37d47bc3ab855f71137ca7151ea5c3e1 Mon Sep 17 00:00:00 2001 From: sravan1213 Date: Mon, 8 Jun 2020 18:42:48 +0530 Subject: [PATCH 0429/1052] Propagate download exception through onDownloadChanged callback --- .../exoplayer2/demo/DemoDownloadService.java | 4 +++- .../exoplayer2/demo/DownloadTracker.java | 4 +++- .../exoplayer2/offline/DownloadManager.java | 23 +++++++++++++------ .../exoplayer2/offline/DownloadService.java | 3 ++- .../testutil/TestDownloadManagerListener.java | 3 ++- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java index 71b1eda7bf..1a2658f162 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java @@ -20,6 +20,7 @@ import static com.google.android.exoplayer2.demo.DemoApplication.DOWNLOAD_NOTIFI import android.app.Notification; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.offline.Download; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadService; @@ -94,7 +95,8 @@ public class DemoDownloadService extends DownloadService { } @Override - public void onDownloadChanged(@NonNull DownloadManager manager, @NonNull Download download) { + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Throwable error) { Notification notification; if (download.state == Download.STATE_COMPLETED) { notification = diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index a36635acb0..4672563894 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -126,7 +126,9 @@ public class DownloadTracker { @Override public void onDownloadChanged( - @NonNull DownloadManager downloadManager, @NonNull Download download) { + @NonNull DownloadManager downloadManager, + @NonNull Download download, + @Nullable Throwable error) { downloads.put(download.request.uri, download); for (Listener listener : listeners) { listener.onDownloadsChanged(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index a45c95470e..aed872ce98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -93,8 +93,10 @@ public final class DownloadManager { * * @param downloadManager The reporting instance. * @param download The state of the download. + * @param error exception occurred when a download is failed */ - default void onDownloadChanged(DownloadManager downloadManager, Download download) {} + default void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Throwable error) {} /** * Called when a download is removed. @@ -614,7 +616,7 @@ public final class DownloadManager { } } else { for (Listener listener : listeners) { - listener.onDownloadChanged(this, updatedDownload); + listener.onDownloadChanged(this, updatedDownload, update.error); } } if (waitingForRequirementsChanged) { @@ -906,7 +908,7 @@ public final class DownloadManager { ArrayList updateList = new ArrayList<>(downloads); for (int i = 0; i < downloads.size(); i++) { DownloadUpdate update = - new DownloadUpdate(downloads.get(i), /* isRemove= */ false, updateList); + new DownloadUpdate(downloads.get(i), /* isRemove= */ false, updateList, null); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); } syncTasks(); @@ -1121,7 +1123,11 @@ public final class DownloadManager { Log.e(TAG, "Failed to update index.", e); } DownloadUpdate update = - new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); + new DownloadUpdate( + download, + /* isRemove= */ false, + new ArrayList<>(downloads), + finalError); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); } @@ -1139,7 +1145,7 @@ public final class DownloadManager { Log.e(TAG, "Failed to remove from database"); } DownloadUpdate update = - new DownloadUpdate(download, /* isRemove= */ true, new ArrayList<>(downloads)); + new DownloadUpdate(download, /* isRemove= */ true, new ArrayList<>(downloads), null); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); } } @@ -1194,7 +1200,7 @@ public final class DownloadManager { Log.e(TAG, "Failed to update index.", e); } DownloadUpdate update = - new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); + new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads), null); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); return download; } @@ -1355,11 +1361,14 @@ public final class DownloadManager { public final Download download; public final boolean isRemove; public final List downloads; + @Nullable public final Throwable error; - public DownloadUpdate(Download download, boolean isRemove, List downloads) { + public DownloadUpdate( + Download download, boolean isRemove, List downloads, @Nullable Throwable error) { this.download = download; this.isRemove = isRemove; this.downloads = downloads; + this.error = error; } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 1c980ca2ef..7b26610ba1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -974,7 +974,8 @@ public abstract class DownloadService extends Service { } @Override - public void onDownloadChanged(DownloadManager downloadManager, Download download) { + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Throwable throwable) { if (downloadService != null) { downloadService.notifyDownloadChanged(download); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java index c3d62fc53b..1d2e03f39c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java @@ -100,7 +100,8 @@ public final class TestDownloadManagerListener implements DownloadManager.Listen } @Override - public void onDownloadChanged(DownloadManager downloadManager, Download download) { + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Throwable error) { if (download.state == Download.STATE_FAILED) { failureReason = download.failureReason; } From f16803de691f150c033a6c0cd422d238c0bc91ca Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 5 Jun 2020 18:50:01 +0100 Subject: [PATCH 0430/1052] Improve ImaAdsLoaderTest ad duration handling Previously the fake ads loader listener would always pass the same ad durations to the fake player, but actually the known ad durations can change during playback. Make the fake behavior more realistic by only exposing durations for ads that have loaded. PiperOrigin-RevId: 314956223 --- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 27bb641d4f..6815bcf870 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -92,7 +92,6 @@ public final class ImaAdsLoaderTest { private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -145,14 +144,14 @@ public final class ImaAdsLoaderTest { @Test public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test public void start_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); @@ -162,7 +161,7 @@ public final class ImaAdsLoaderTest { @Test public void start_withPlaceholderContent_initializedAdsLoader() { Timeline placeholderTimeline = new DummyTimeline(MediaItem.fromUri(Uri.EMPTY)); - setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(placeholderTimeline, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); // We'll only create the rendering settings when initializing the ads loader. @@ -171,26 +170,25 @@ public final class ImaAdsLoaderTest { @Test public void start_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ 0) - .withAdDurationsUs(ADS_DURATIONS_US) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void startAfterRelease() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test public void startAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -217,7 +215,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -250,14 +248,14 @@ public final class ImaAdsLoaderTest { .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } @Test public void playback_withPostrollFetchError_marksAdAsInErrorState() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, new Float[] {-1f}); + setupPlayback(CONTENT_TIMELINE, new Float[] {-1f}); // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -267,7 +265,7 @@ public final class ImaAdsLoaderTest { .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) - .withAdDurationsUs(ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } @@ -281,7 +279,6 @@ public final class ImaAdsLoaderTest { + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; setupPlayback( CONTENT_TIMELINE, - ADS_DURATIONS_US, new Float[] {(float) adGroupTimeUs / C.MICROS_PER_SECOND}); // Advance playback to just before the midroll and simulate buffering. @@ -295,8 +292,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ adGroupTimeUs) - .withContentDurationUs(CONTENT_PERIOD_DURATION_US) - .withAdDurationsUs(ADS_DURATIONS_US)); + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test @@ -308,7 +304,6 @@ public final class ImaAdsLoaderTest { + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; setupPlayback( CONTENT_TIMELINE, - ADS_DURATIONS_US, new Float[] {(float) adGroupTimeUs / C.MICROS_PER_SECOND}); // Advance playback to just before the midroll and simulate buffering. @@ -323,14 +318,14 @@ public final class ImaAdsLoaderTest { .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ adGroupTimeUs) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) - .withAdDurationsUs(ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } @Test public void stop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); @@ -340,9 +335,9 @@ public final class ImaAdsLoaderTest { inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } - private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) { + private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { fakeExoPlayer = new FakePlayer(); - adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs); + adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); imaAdsLoader = new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) @@ -356,7 +351,7 @@ public final class ImaAdsLoaderTest { ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class); doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture()); when(mockAdsRequest.getUserRequestContext()) - .thenAnswer((Answer) invocation -> userRequestContextCaptor.getValue()); + .thenAnswer(invocation -> userRequestContextCaptor.getValue()); List adsLoadedListeners = new ArrayList<>(); doAnswer( @@ -429,19 +424,21 @@ public final class ImaAdsLoaderTest { private final FakePlayer fakeExoPlayer; private final Timeline contentTimeline; - private final long[][] adDurationsUs; public AdPlaybackState adPlaybackState; - public TestAdsLoaderListener( - FakePlayer fakeExoPlayer, Timeline contentTimeline, long[][] adDurationsUs) { + public TestAdsLoaderListener(FakePlayer fakeExoPlayer, Timeline contentTimeline) { this.fakeExoPlayer = fakeExoPlayer; this.contentTimeline = contentTimeline; - this.adDurationsUs = adDurationsUs; } @Override public void onAdPlaybackState(AdPlaybackState adPlaybackState) { + long[][] adDurationsUs = new long[adPlaybackState.adGroupCount][]; + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + adDurationsUs[adGroupIndex] = new long[adPlaybackState.adGroups[adGroupIndex].uris.length]; + Arrays.fill(adDurationsUs[adGroupIndex], TEST_AD_DURATION_US); + } adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); this.adPlaybackState = adPlaybackState; fakeExoPlayer.updateTimeline( From 680d955851249284dc92a28bb8c4796b34a342ae Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 5 Jun 2020 19:29:06 +0100 Subject: [PATCH 0431/1052] Adjust input timestamps for the Codec2 MP3 decoder. Output timestamps are calculated by the codec based on the buffers, which is offset in Codec2. This adjusts the input timestamps as they are passed in so they will match the output timestamps produced by the MediaCodec. PiperOrigin-RevId: 314963830 --- RELEASENOTES.md | 2 + .../mediacodec/C2Mp3TimestampTracker.java | 86 +++++++++++++++++++ .../mediacodec/MediaCodecRenderer.java | 15 ++++ .../mediacodec/C2Mp3TimestampTrackerTest.java | 80 +++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ce8541ed1e..8376d41d69 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -152,6 +152,8 @@ * Check `DefaultAudioSink` supports passthrough, in addition to checking the `AudioCapabilities` ([#7404](https://github.com/google/ExoPlayer/issues/7404)). + * Adjust input timestamps in `MediaCodecRenderer` to account for the + Codec2 MP3 decoder having lower timestamps on the output side. * DASH: * Enable support for embedded CEA-708. * HLS: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java new file mode 100644 index 0000000000..c1c9e6f164 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.mediacodec; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.audio.MpegAudioUtil; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.Log; + +/** + * Tracks the number of processed samples to calculate an accurate current timestamp, matching the + * calculations made in the Codec2 Mp3 decoder. + */ +/* package */ final class C2Mp3TimestampTracker { + + // Mirroring the actual codec, as can be found at + // https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/codec2/components/mp3/C2SoftMp3Dec.h;l=55;drc=3665390c9d32a917398b240c5a46ced07a3b65eb + private static final long DECODER_DELAY_SAMPLES = 529; + private static final String TAG = "C2Mp3TimestampTracker"; + + private long processedSamples; + private long anchorTimestampUs; + private boolean audioHeaderInvalid; + + /** + * Resets the timestamp tracker. + * + *

      This should be done when the codec is flushed. + */ + public void reset() { + processedSamples = 0; + anchorTimestampUs = 0; + audioHeaderInvalid = false; + } + + /** + * Updates the tracker with the given input buffer and returns the expected output timestamp. + * + * @param format The format associated with the buffer. + * @param buffer The current input buffer. + * @return The expected output presentation time, in microseconds. + */ + public long updateAndGetPresentationTimeUs(Format format, DecoderInputBuffer buffer) { + if (audioHeaderInvalid || buffer.data == null) { + return buffer.timeUs; + } + + // These calculations mirror the timestamp calculations in the Codec2 Mp3 Decoder. + // https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/codec2/components/mp3/C2SoftMp3Dec.cpp;l=464;drc=ed134640332fea70ca4b05694289d91a5265bb46 + long presentationTimeUs = processedSamples * C.MICROS_PER_SECOND / format.sampleRate; + int sampleHeaderData = 0; + for (int i = 0; i < 4; i++) { + sampleHeaderData <<= 8; + sampleHeaderData |= buffer.data.get(i) & 0xFF; + } + + int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(sampleHeaderData); + if (frameCount == C.LENGTH_UNSET) { + Log.w(TAG, "MPEG audio header is invalid."); + return buffer.timeUs; + } + long outSize = frameCount * format.channelCount * 2L; + boolean isFirstSample = processedSamples == 0; + long outOffset = 0; + if (isFirstSample) { + anchorTimestampUs = buffer.timeUs; + outOffset = DECODER_DELAY_SAMPLES; + } + processedSamples += (outSize / (format.channelCount * 2L)) - outOffset; + return anchorTimestampUs + presentationTimeUs; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 571e08162c..c6fb5c2a0d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -395,6 +395,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private boolean codecNeedsAdaptationWorkaroundBuffer; private boolean shouldSkipAdaptationWorkaroundOutputBuffer; private boolean codecNeedsEosPropagation; + @Nullable private C2Mp3TimestampTracker c2Mp3TimestampTracker; private ByteBuffer[] inputBuffers; private ByteBuffer[] outputBuffers; private long codecHotswapDeadlineMs; @@ -911,6 +912,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { decodeOnlyPresentationTimestamps.clear(); largestQueuedPresentationTimeUs = C.TIME_UNSET; lastBufferInStreamPresentationTimeUs = C.TIME_UNSET; + if (c2Mp3TimestampTracker != null) { + c2Mp3TimestampTracker.reset(); + } codecDrainState = DRAIN_STATE_NONE; codecDrainAction = DRAIN_ACTION_NONE; // Reconfiguration data sent shortly before the flush may not have been processed by the @@ -944,6 +948,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecNeedsEosOutputExceptionWorkaround = false; codecNeedsMonoChannelCountWorkaround = false; codecNeedsEosPropagation = false; + c2Mp3TimestampTracker = null; codecReconfigured = false; codecReconfigurationState = RECONFIGURATION_STATE_NONE; resetCodecBuffers(); @@ -1157,6 +1162,10 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecNeedsMonoChannelCountWorkaround(codecName, codecFormat); codecNeedsEosPropagation = codecNeedsEosPropagationWorkaround(codecInfo) || getCodecNeedsEosPropagation(); + if ("c2.android.mp3.decoder".equals(codecInfo.name)) { + c2Mp3TimestampTracker = new C2Mp3TimestampTracker(); + } + if (getState() == STATE_STARTED) { codecHotswapDeadlineMs = SystemClock.elapsedRealtime() + MAX_CODEC_HOTSWAP_TIME_MS; } @@ -1369,6 +1378,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } long presentationTimeUs = buffer.timeUs; + + if (c2Mp3TimestampTracker != null) { + presentationTimeUs = + c2Mp3TimestampTracker.updateAndGetPresentationTimeUs(inputFormat, buffer); + } + if (buffer.isDecodeOnly()) { decodeOnlyPresentationTimestamps.add(presentationTimeUs); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java new file mode 100644 index 0000000000..ce8dce716a --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.mediacodec; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.MimeTypes; +import java.nio.ByteBuffer; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link C2Mp3TimestampTracker}. */ +@RunWith(AndroidJUnit4.class) +public final class C2Mp3TimestampTrackerTest { + + private static final Format AUDIO_MP3 = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_MPEG) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(2) + .setSampleRate(44_100) + .build(); + + private DecoderInputBuffer buffer; + private C2Mp3TimestampTracker timestampTracker; + + @Before + public void setUp() { + buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); + timestampTracker = new C2Mp3TimestampTracker(); + buffer.data = ByteBuffer.wrap(new byte[] {-1, -5, -24, 60}); + buffer.timeUs = 100_000; + } + + @Test + public void whenUpdateCalledMultipleTimes_timestampsIncrease() { + long first = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + long second = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + long third = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + + assertThat(second).isGreaterThan(first); + assertThat(third).isGreaterThan(second); + } + + @Test + public void whenResetCalled_timestampsDecrease() { + long first = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + long second = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + timestampTracker.reset(); + long third = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + + assertThat(second).isGreaterThan(first); + assertThat(third).isLessThan(second); + } + + @Test + public void whenBufferTimeIsNotZero_firstSampleIsOffset() { + long first = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + + assertThat(first).isEqualTo(buffer.timeUs); + } +} From bc7310240d439901cd014c4da0af625132b85718 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 8 Jun 2020 11:21:54 +0100 Subject: [PATCH 0432/1052] Suppress repeated logging for invalid MP3 headers PiperOrigin-RevId: 315243493 --- .../exoplayer2/mediacodec/C2Mp3TimestampTracker.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java index c1c9e6f164..a90ce89ebb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java @@ -34,7 +34,7 @@ import com.google.android.exoplayer2.util.Log; private long processedSamples; private long anchorTimestampUs; - private boolean audioHeaderInvalid; + private boolean seenInvalidMpegAudioHeader; /** * Resets the timestamp tracker. @@ -44,7 +44,7 @@ import com.google.android.exoplayer2.util.Log; public void reset() { processedSamples = 0; anchorTimestampUs = 0; - audioHeaderInvalid = false; + seenInvalidMpegAudioHeader = false; } /** @@ -55,7 +55,7 @@ import com.google.android.exoplayer2.util.Log; * @return The expected output presentation time, in microseconds. */ public long updateAndGetPresentationTimeUs(Format format, DecoderInputBuffer buffer) { - if (audioHeaderInvalid || buffer.data == null) { + if (seenInvalidMpegAudioHeader || buffer.data == null) { return buffer.timeUs; } @@ -70,6 +70,7 @@ import com.google.android.exoplayer2.util.Log; int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(sampleHeaderData); if (frameCount == C.LENGTH_UNSET) { + seenInvalidMpegAudioHeader = true; Log.w(TAG, "MPEG audio header is invalid."); return buffer.timeUs; } From b7486f4883384ab564531db2a5303e33dd26d3d9 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 8 Jun 2020 13:38:21 +0100 Subject: [PATCH 0433/1052] Add a test for 33-bit HLS WebVTT wraparound This also gives some general test coverage for HLS' WebvttExtractor Issue: #7462 PiperOrigin-RevId: 315257252 --- library/hls/build.gradle | 3 +++ .../source/hls/WebvttExtractorTest.java | 24 +++++++++++++++++++ .../assets/webvtt/with_x-timestamp-map_header | 13 ++++++++++ .../webvtt/with_x-timestamp-map_header.dump | 16 +++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 testdata/src/test/assets/webvtt/with_x-timestamp-map_header create mode 100644 testdata/src/test/assets/webvtt/with_x-timestamp-map_header.dump diff --git a/library/hls/build.gradle b/library/hls/build.gradle index 4deef3b5f9..152fd35dff 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -33,6 +33,8 @@ android { } } + sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' + testOptions.unitTests.includeAndroidResources = true } @@ -44,6 +46,7 @@ dependencies { compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') + testImplementation project(modulePrefix + 'testdata') testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java index 2f7f8e3fc0..5f1169e222 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java @@ -17,9 +17,12 @@ package com.google.android.exoplayer2.source.hls; import static com.google.common.truth.Truth.assertThat; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.FakeExtractorOutput; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.EOFException; import java.io.IOException; @@ -63,6 +66,27 @@ public class WebvttExtractorTest { assertThat(sniffData(data)).isFalse(); } + @Test + public void read_handlesLargeCueTimestamps() throws Exception { + TimestampAdjuster timestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + // Prime the TimestampAdjuster with a close-ish timestamp (5s before the first cue). + timestampAdjuster.adjustTsTimestamp(384615190); + WebvttExtractor extractor = new WebvttExtractor(/* language= */ null, timestampAdjuster); + // We can't use ExtractorAsserts because WebvttExtractor doesn't fulfill the whole Extractor + // interface (e.g. throws an exception from seek()). + FakeExtractorOutput output = + TestUtil.extractAllSamplesFromFile( + extractor, + ApplicationProvider.getApplicationContext(), + "webvtt/with_x-timestamp-map_header"); + + // The output has a ~5s sampleTime and a large, negative subsampleOffset because the cue + // timestamps are ~10 days ahead of the PTS (due to wrapping) so the offset is used to ensure + // they're rendered at the right time. + output.assertOutput( + ApplicationProvider.getApplicationContext(), "webvtt/with_x-timestamp-map_header.dump"); + } + private static boolean sniffData(byte[] data) throws IOException { ExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); try { diff --git a/testdata/src/test/assets/webvtt/with_x-timestamp-map_header b/testdata/src/test/assets/webvtt/with_x-timestamp-map_header new file mode 100644 index 0000000000..fb4edfdb7b --- /dev/null +++ b/testdata/src/test/assets/webvtt/with_x-timestamp-map_header @@ -0,0 +1,13 @@ +WEBVTT +X-TIMESTAMP-MAP=LOCAL:1:11:16.443,MPEGTS:384879885 + +NOTE +X-TIMESTAMP-MAP is defined in RFC 8216 section 3.5 (HLS spec). +The cue times below are deliberately greater than 2^33 in 90kHz PTS clock, +to test wraparound logic. + +266:18:35.679 --> 266:18:37.305 + Cue text one + +266:18:37.433 --> 266:18:38.276 + Cue text two diff --git a/testdata/src/test/assets/webvtt/with_x-timestamp-map_header.dump b/testdata/src/test/assets/webvtt/with_x-timestamp-map_header.dump new file mode 100644 index 0000000000..272155e8a8 --- /dev/null +++ b/testdata/src/test/assets/webvtt/with_x-timestamp-map_header.dump @@ -0,0 +1,16 @@ +seekMap: + isSeekable = false + duration = UNSET TIME + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 1 +track 0: + total output bytes = 324 + sample count = 1 + format 0: + sampleMimeType = text/vtt + subsampleOffsetUs = -958710678845 + sample 0: + time = 5000155 + flags = 1 + data = length 324, hash C0D159A2 +tracksEnded = true From 770df8636ae9f05c9760806712f5e8220ffa72a9 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 8 Jun 2020 13:55:39 +0100 Subject: [PATCH 0434/1052] CEA-608: Don't assume roll-up captions are at the bottom of the screen ANSI/CTA-608-E R-2014 Annex B.5 says: "The concept of a movable base row for a roll-up caption is new." It means "new" compared to TC1 and TC2 (released in or before 1985). Issue: #7475 PiperOrigin-RevId: 315258859 --- RELEASENOTES.md | 2 ++ .../com/google/android/exoplayer2/text/cea/Cea608Decoder.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8376d41d69..3f451c8405 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -122,6 +122,8 @@ * Ignore excess characters in CEA-608 lines (max length is 32) ([#7341](https://github.com/google/ExoPlayer/issues/7341)). * Add support for WebVTT's `ruby-position` CSS property. + * Don't assume CEA-608 captions are in the bottom half of the + screen([#7475](https://github.com/google/ExoPlayer/issues/7475)). * DRM: * Add support for attaching DRM sessions to clear content in the demo app. * Remove `DrmSessionManager` references from all renderers. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index dcb3d96841..1dfbff2598 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -947,7 +947,7 @@ public final class Cea608Decoder extends CeaDecoder { int lineAnchor; int line; // Note: Row indices are in the range [1-15]. - if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) { + if (row > (BASE_ROW / 2)) { lineAnchor = Cue.ANCHOR_TYPE_END; line = row - BASE_ROW; // Two line adjustments. The first is because line indices from the bottom of the window From c37af0b5433853edb8d4972a6e83ee0c76f4144e Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Mon, 8 Jun 2020 16:53:54 +0100 Subject: [PATCH 0435/1052] Make ChunkExtractor return a ChunkIndex instead of a SeekMap PiperOrigin-RevId: 315283645 --- .../exoplayer2/source/chunk/BundledChunkExtractor.java | 5 +++-- .../android/exoplayer2/source/chunk/ChunkExtractor.java | 8 ++++---- .../google/android/exoplayer2/source/dash/DashUtil.java | 3 ++- .../exoplayer2/source/dash/DefaultDashChunkSource.java | 8 +++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java index 6038ad2040..0e2eae27d2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java @@ -21,6 +21,7 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; @@ -74,8 +75,8 @@ public final class BundledChunkExtractor implements ExtractorOutput, ChunkExtrac @Override @Nullable - public SeekMap getSeekMap() { - return seekMap; + public ChunkIndex getChunkIndex() { + return seekMap instanceof ChunkIndex ? (ChunkIndex) seekMap : null; } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java index ee4daea725..e0ae50bd86 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java @@ -18,9 +18,9 @@ package com.google.android.exoplayer2.source.chunk; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import java.io.IOException; @@ -50,11 +50,11 @@ public interface ChunkExtractor { } /** - * Returns the {@link SeekMap} most recently output by the extractor, or null if the extractor has - * not output a {@link SeekMap}. + * Returns the {@link ChunkIndex} most recently obtained from the chunks, or null if a {@link + * ChunkIndex} has not been obtained. */ @Nullable - SeekMap getSeekMap(); + ChunkIndex getChunkIndex(); /** * Returns the sample {@link Format}s for the tracks identified by the extractor, or null if the diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java index 474902dd5b..10c69ce65c 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java @@ -136,9 +136,10 @@ public final class DashUtil { @Nullable public static ChunkIndex loadChunkIndex( DataSource dataSource, int trackType, Representation representation) throws IOException { + @Nullable ChunkExtractor chunkExtractor = loadInitializationData(dataSource, trackType, representation, true); - return chunkExtractor == null ? null : (ChunkIndex) chunkExtractor.getSeekMap(); + return chunkExtractor == null ? null : chunkExtractor.getChunkIndex(); } /** diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index 29f62f0aca..e34afaeb48 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -24,7 +24,6 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.Extractor; -import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; @@ -400,13 +399,12 @@ public class DefaultDashChunkSource implements DashChunkSource { // from the stream. If the manifest defines an index then the stream shouldn't, but in cases // where it does we should ignore it. if (representationHolder.segmentIndex == null) { - SeekMap seekMap = representationHolder.chunkExtractor.getSeekMap(); - if (seekMap != null) { + @Nullable ChunkIndex chunkIndex = representationHolder.chunkExtractor.getChunkIndex(); + if (chunkIndex != null) { representationHolders[trackIndex] = representationHolder.copyWithNewSegmentIndex( new DashWrappingSegmentIndex( - (ChunkIndex) seekMap, - representationHolder.representation.presentationTimeOffsetUs)); + chunkIndex, representationHolder.representation.presentationTimeOffsetUs)); } } } From 99d805f6a863b848de85cf1d306861532b8f5174 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 8 Jun 2020 16:55:06 +0100 Subject: [PATCH 0436/1052] Make media item of SinglePeriodTimeline non-null This change makes the media item argument in any constructors of the SinglePeriodTimeline non-null. Further a dummy media item is created for deprecated constructors using a tag only. PiperOrigin-RevId: 315283842 --- .../source/SinglePeriodTimeline.java | 22 ++++++++---- .../exoplayer2/MediaPeriodQueueTest.java | 2 +- .../source/ClippingMediaSourceTest.java | 36 +++++++++---------- .../source/SinglePeriodTimelineTest.java | 26 ++------------ .../source/ads/AdsMediaSourceTest.java | 5 +-- 5 files changed, 40 insertions(+), 51 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java index f744525d9d..c841829c48 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; @@ -27,6 +30,11 @@ import com.google.android.exoplayer2.util.Assertions; public final class SinglePeriodTimeline extends Timeline { private static final Object UID = new Object(); + private static final MediaItem MEDIA_ITEM = + new MediaItem.Builder() + .setMediaId("com.google.android.exoplayer2.source.SinglePeriodTimeline") + .setUri(Uri.EMPTY) + .build(); private final long presentationStartTimeMs; private final long windowStartTimeMs; @@ -84,7 +92,7 @@ public final class SinglePeriodTimeline extends Timeline { boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable MediaItem mediaItem) { + MediaItem mediaItem) { this( durationUs, durationUs, @@ -154,7 +162,7 @@ public final class SinglePeriodTimeline extends Timeline { boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable MediaItem mediaItem) { + MediaItem mediaItem) { this( /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, @@ -200,7 +208,7 @@ public final class SinglePeriodTimeline extends Timeline { isDynamic, isLive, manifest, - /* mediaItem= */ null, + MEDIA_ITEM.buildUpon().setTag(tag).build(), tag); } @@ -239,7 +247,7 @@ public final class SinglePeriodTimeline extends Timeline { boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable MediaItem mediaItem) { + MediaItem mediaItem) { this( presentationStartTimeMs, windowStartTimeMs, @@ -268,7 +276,7 @@ public final class SinglePeriodTimeline extends Timeline { boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable MediaItem mediaItem, + MediaItem mediaItem, @Nullable Object tag) { this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; @@ -281,7 +289,7 @@ public final class SinglePeriodTimeline extends Timeline { this.isDynamic = isDynamic; this.isLive = isLive; this.manifest = manifest; - this.mediaItem = mediaItem; + this.mediaItem = checkNotNull(mediaItem); this.tag = tag; } @@ -351,7 +359,7 @@ public final class SinglePeriodTimeline extends Timeline { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { Assertions.checkIndex(periodIndex, 0, 1); - Object uid = setIds ? UID : null; + @Nullable Object uid = setIds ? UID : null; return period.set(/* id= */ null, uid, 0, periodDurationUs, -windowPositionInPeriodUs); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index fd99e66425..0ca5dd60ef 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -57,7 +57,7 @@ public final class MediaPeriodQueueTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); private static final Uri AD_URI = Uri.EMPTY; private MediaPeriodQueue mediaPeriodQueue; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index d63531597e..d3e85233e9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -74,7 +74,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US); @@ -95,7 +95,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); // If the unseekable window isn't clipped, clipping succeeds. getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US); @@ -117,7 +117,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); // If the unseekable window isn't clipped, clipping succeeds. getClippedTimeline(timeline, /* startUs= */ 0, TEST_PERIOD_DURATION_US); @@ -139,7 +139,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, TEST_CLIP_AMOUNT_US, TEST_PERIOD_DURATION_US); @@ -158,7 +158,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US); @@ -194,7 +194,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); // When clipping to the end, the clipped timeline should also have a duration. Timeline clippedTimeline = @@ -213,7 +213,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); // When clipping to the end, the clipped timeline should also have an unset duration. Timeline clippedTimeline = @@ -231,7 +231,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline( @@ -254,7 +254,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, /* durationUs= */ TEST_CLIP_AMOUNT_US); assertThat(clippedTimeline.getWindow(0, window).getDurationUs()).isEqualTo(TEST_CLIP_AMOUNT_US); @@ -277,7 +277,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 3 * TEST_PERIOD_DURATION_US, @@ -288,7 +288,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -328,7 +328,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 4 * TEST_PERIOD_DURATION_US, @@ -339,7 +339,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -379,7 +379,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 3 * TEST_PERIOD_DURATION_US, @@ -390,7 +390,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -431,7 +431,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 4 * TEST_PERIOD_DURATION_US, @@ -442,7 +442,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -561,7 +561,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); FakeMediaSource fakeMediaSource = new FakeMediaSource(timeline) { @Override diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java index ea0b9f1538..27b5381fef 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java @@ -50,7 +50,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); // Should return null with any positive position projection. Pair position = timeline.getPeriodPosition(window, period, 0, C.TIME_UNSET, 1); assertThat(position).isNull(); @@ -73,7 +73,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); // Should return null with a positive position projection beyond window duration. Pair position = timeline.getPeriodPosition(window, period, 0, C.TIME_UNSET, windowDurationUs + 1); @@ -107,26 +107,6 @@ public final class SinglePeriodTimelineTest { .isNotNull(); } - @Test - public void setNullMediaItem_returnsFallbackMediaItem_butUsesDefaultUid() { - SinglePeriodTimeline timeline = - new SinglePeriodTimeline( - /* durationUs= */ C.TIME_UNSET, - /* isSeekable= */ false, - /* isDynamic= */ false, - /* isLive= */ false, - /* manifest= */ null, - /* mediaItem= */ null); - - assertThat(timeline.getWindow(/* windowIndex= */ 0, window).mediaItem.mediaId) - .isEqualTo("com.google.android.exoplayer2.Timeline"); - assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).id).isNull(); - assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).id).isNull(); - assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).uid).isNull(); - assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).uid) - .isNotNull(); - } - @Test public void getWindow_setsTag() { Object tag = new Object(); @@ -171,7 +151,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); Object uid = timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).uid; assertThat(timeline.getIndexOfPeriod(uid)).isEqualTo(0); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java index 2f398bbfa0..ce0603aaef 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -28,6 +28,7 @@ import android.net.Uri; import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; @@ -61,7 +62,7 @@ public final class AdsMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); private static final Object PREROLL_AD_PERIOD_UID = PREROLL_AD_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); @@ -73,7 +74,7 @@ public final class AdsMediaSourceTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* mediaItem= */ null); + MediaItem.fromUri(Uri.EMPTY)); private static final Object CONTENT_PERIOD_UID = CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); From b1e56304a1fda8075fc637074927c0886f49fdf1 Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 8 Jun 2020 16:55:41 +0100 Subject: [PATCH 0437/1052] Add support for inferring file format from MIME type PiperOrigin-RevId: 315283926 --- .../android/exoplayer2/util/FileTypes.java | 75 ++++++++++++++++++- .../android/exoplayer2/util/MimeTypes.java | 23 ++++++ .../google/android/exoplayer2/util/Util.java | 1 + .../exoplayer2/util/FileTypesTest.java | 62 ++++++++++++--- .../extractor/DefaultExtractorsFactory.java | 8 +- .../hls/DefaultHlsExtractorFactory.java | 44 ++++++----- 6 files changed, 174 insertions(+), 39 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java index 62fdb48e01..d4b87abfdd 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java @@ -15,11 +15,17 @@ */ package com.google.android.exoplayer2.util; +import static com.google.android.exoplayer2.util.MimeTypes.normalizeMimeType; + import android.net.Uri; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Map; /** Defines common file type constants and helper methods. */ public final class FileTypes { @@ -64,6 +70,8 @@ public final class FileTypes { /** File type for the WebVTT format. */ public static final int WEBVTT = 13; + @VisibleForTesting /* package */ static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final String EXTENSION_AC3 = ".ac3"; private static final String EXTENSION_EC3 = ".ec3"; private static final String EXTENSION_AC4 = ".ac4"; @@ -94,13 +102,72 @@ public final class FileTypes { private FileTypes() {} + /** Returns the {@link Type} corresponding to the response headers provided. */ + @FileTypes.Type + public static int inferFileTypeFromResponseHeaders(Map> responseHeaders) { + @Nullable List contentTypes = responseHeaders.get(HEADER_CONTENT_TYPE); + @Nullable + String mimeType = contentTypes == null || contentTypes.isEmpty() ? null : contentTypes.get(0); + return inferFileTypeFromMimeType(mimeType); + } + /** - * Returns the {@link Type} corresponding to the filename extension of the provided {@link Uri}. - * The filename is considered to be the last segment of the {@link Uri} path. + * Returns the {@link Type} corresponding to the MIME type provided. + * + *

      Returns {@link #UNKNOWN} if the mime type is {@code null}. */ @FileTypes.Type - public static int getFormatFromExtension(Uri uri) { - String filename = uri.getLastPathSegment(); + public static int inferFileTypeFromMimeType(@Nullable String mimeType) { + if (mimeType == null) { + return FileTypes.UNKNOWN; + } + mimeType = normalizeMimeType(mimeType); + switch (mimeType) { + case MimeTypes.AUDIO_AC3: + case MimeTypes.AUDIO_E_AC3: + case MimeTypes.AUDIO_E_AC3_JOC: + return FileTypes.AC3; + case MimeTypes.AUDIO_AC4: + return FileTypes.AC4; + case MimeTypes.AUDIO_AMR: + case MimeTypes.AUDIO_AMR_NB: + case MimeTypes.AUDIO_AMR_WB: + return FileTypes.AMR; + case MimeTypes.AUDIO_FLAC: + return FileTypes.FLAC; + case MimeTypes.VIDEO_FLV: + return FileTypes.FLV; + case MimeTypes.VIDEO_MATROSKA: + case MimeTypes.AUDIO_MATROSKA: + case MimeTypes.VIDEO_WEBM: + case MimeTypes.AUDIO_WEBM: + case MimeTypes.APPLICATION_WEBM: + return FileTypes.MATROSKA; + case MimeTypes.AUDIO_MPEG: + return FileTypes.MP3; + case MimeTypes.VIDEO_MP4: + case MimeTypes.AUDIO_MP4: + case MimeTypes.APPLICATION_MP4: + return FileTypes.MP4; + case MimeTypes.AUDIO_OGG: + return FileTypes.OGG; + case MimeTypes.VIDEO_PS: + return FileTypes.PS; + case MimeTypes.VIDEO_MP2T: + return FileTypes.TS; + case MimeTypes.AUDIO_WAV: + return FileTypes.WAV; + case MimeTypes.TEXT_VTT: + return FileTypes.WEBVTT; + default: + return FileTypes.UNKNOWN; + } + } + + /** Returns the {@link Type} corresponding to the {@link Uri} provided. */ + @FileTypes.Type + public static int inferFileTypeFromUri(Uri uri) { + @Nullable String filename = uri.getLastPathSegment(); if (filename == null) { return FileTypes.UNKNOWN; } else if (filename.endsWith(EXTENSION_AC3) || filename.endsWith(EXTENSION_EC3)) { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index e2055a24f0..a3bc395574 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -47,6 +47,7 @@ public final class MimeTypes { public static final String BASE_TYPE_APPLICATION = "application"; public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; + public static final String VIDEO_MATROSKA = BASE_TYPE_VIDEO + "/x-matroska"; public static final String VIDEO_WEBM = BASE_TYPE_VIDEO + "/webm"; public static final String VIDEO_H263 = BASE_TYPE_VIDEO + "/3gpp"; public static final String VIDEO_H264 = BASE_TYPE_VIDEO + "/avc"; @@ -67,6 +68,7 @@ public final class MimeTypes { public static final String AUDIO_MP4 = BASE_TYPE_AUDIO + "/mp4"; public static final String AUDIO_AAC = BASE_TYPE_AUDIO + "/mp4a-latm"; + public static final String AUDIO_MATROSKA = BASE_TYPE_AUDIO + "/x-matroska"; public static final String AUDIO_WEBM = BASE_TYPE_AUDIO + "/webm"; public static final String AUDIO_MPEG = BASE_TYPE_AUDIO + "/mpeg"; public static final String AUDIO_MPEG_L1 = BASE_TYPE_AUDIO + "/mpeg-L1"; @@ -91,6 +93,7 @@ public final class MimeTypes { public static final String AUDIO_ALAC = BASE_TYPE_AUDIO + "/alac"; public static final String AUDIO_MSGSM = BASE_TYPE_AUDIO + "/gsm"; public static final String AUDIO_OGG = BASE_TYPE_AUDIO + "/ogg"; + public static final String AUDIO_WAV = BASE_TYPE_AUDIO + "/wav"; public static final String AUDIO_UNKNOWN = BASE_TYPE_AUDIO + "/x-unknown"; public static final String TEXT_VTT = BASE_TYPE_TEXT + "/vtt"; @@ -502,6 +505,26 @@ public final class MimeTypes { return new Mp4aObjectType(objectTypeIndication, audioObjectTypeIndication); } + /** + * Normalizes the MIME type provided so that equivalent MIME types are uniquely represented. + * + * @param mimeType The MIME type to normalize. The MIME type provided is returned if its + * normalized form is unknown. + * @return The normalized MIME type. + */ + public static String normalizeMimeType(String mimeType) { + switch (mimeType) { + case BASE_TYPE_AUDIO + "/x-flac": + return AUDIO_FLAC; + case BASE_TYPE_AUDIO + "/mp3": + return AUDIO_MPEG; + case BASE_TYPE_AUDIO + "/x-wav": + return AUDIO_WAV; + default: + return mimeType; + } + } + /** * Returns the top-level type of {@code mimeType}, or null if {@code mimeType} is null or does not * contain a forward slash character ({@code '/'}). diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 888f0afa16..09303c4a9c 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -1676,6 +1676,7 @@ public final class Util { * @param mimeType If not null, used to infer the type. * @return The content type. */ + @C.ContentType public static int inferContentTypeWithMimeType(Uri uri, @Nullable String mimeType) { if (mimeType == null) { return Util.inferContentType(uri); diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java index ed7c17055d..aee23f9c17 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java @@ -15,11 +15,17 @@ */ package com.google.android.exoplayer2.util; -import static com.google.android.exoplayer2.util.FileTypes.getFormatFromExtension; +import static com.google.android.exoplayer2.util.FileTypes.HEADER_CONTENT_TYPE; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromMimeType; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; import static com.google.common.truth.Truth.assertThat; import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; @@ -28,30 +34,64 @@ import org.junit.runner.RunWith; public class FileTypesTest { @Test - public void getFormatFromExtension_withExtension_returnsExpectedFormat() { - assertThat(getFormatFromExtension(Uri.parse("filename.mp3"))).isEqualTo(FileTypes.MP3); + public void inferFileFormat_fromResponseHeaders_returnsExpectedFormat() { + Map> responseHeaders = new HashMap<>(); + responseHeaders.put(HEADER_CONTENT_TYPE, Collections.singletonList(MimeTypes.VIDEO_MP4)); + + assertThat(FileTypes.inferFileTypeFromResponseHeaders(responseHeaders)) + .isEqualTo(FileTypes.MP4); } @Test - public void getFormatFromExtension_withExtensionPrefix_returnsExpectedFormat() { - assertThat(getFormatFromExtension(Uri.parse("filename.mka"))).isEqualTo(FileTypes.MATROSKA); + public void inferFileFormat_fromResponseHeadersWithUnknownContentType_returnsUnknownFormat() { + Map> responseHeaders = new HashMap<>(); + responseHeaders.put(HEADER_CONTENT_TYPE, Collections.singletonList("unknown")); + + assertThat(FileTypes.inferFileTypeFromResponseHeaders(responseHeaders)) + .isEqualTo(FileTypes.UNKNOWN); } @Test - public void getFormatFromExtension_withUnknownExtension_returnsUnknownFormat() { - assertThat(getFormatFromExtension(Uri.parse("filename.unknown"))).isEqualTo(FileTypes.UNKNOWN); + public void inferFileFormat_fromResponseHeadersWithoutContentType_returnsUnknownFormat() { + assertThat(FileTypes.inferFileTypeFromResponseHeaders(new HashMap<>())) + .isEqualTo(FileTypes.UNKNOWN); } @Test - public void getFormatFromExtension_withUriNotEndingWithFilename_returnsExpectedFormat() { + public void inferFileFormat_fromMimeType_returnsExpectedFormat() { + assertThat(FileTypes.inferFileTypeFromMimeType("audio/x-flac")).isEqualTo(FileTypes.FLAC); + } + + @Test + public void inferFileFormat_fromUnknownMimeType_returnsUnknownFormat() { + assertThat(inferFileTypeFromMimeType(/* mimeType= */ "unknown")).isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromNullMimeType_returnsUnknownFormat() { + assertThat(inferFileTypeFromMimeType(/* mimeType= */ null)).isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromUri_returnsExpectedFormat() { assertThat( - getFormatFromExtension( + inferFileTypeFromUri( Uri.parse("http://www.example.com/filename.mp3?query=myquery#fragment"))) .isEqualTo(FileTypes.MP3); } @Test - public void getFormatFromExtension_withNullFilename_returnsUnknownFormat() { - assertThat(getFormatFromExtension(Uri.EMPTY)).isEqualTo(FileTypes.UNKNOWN); + public void inferFileFormat_fromUriWithExtensionPrefix_returnsExpectedFormat() { + assertThat(inferFileTypeFromUri(Uri.parse("filename.mka"))).isEqualTo(FileTypes.MATROSKA); + } + + @Test + public void inferFileFormat_fromUriWithUnknownExtension_returnsUnknownFormat() { + assertThat(inferFileTypeFromUri(Uri.parse("filename.unknown"))).isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromEmptyUri_returnsUnknownFormat() { + assertThat(inferFileTypeFromUri(Uri.EMPTY)).isEqualTo(FileTypes.UNKNOWN); } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 3e329573ba..585871635c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.extractor; -import static com.google.android.exoplayer2.util.FileTypes.getFormatFromExtension; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; import android.net.Uri; import androidx.annotation.Nullable; @@ -272,11 +272,11 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { public synchronized Extractor[] createExtractors(Uri uri) { List extractors = new ArrayList<>(/* initialCapacity= */ 14); - @FileTypes.Type int extensionFormat = getFormatFromExtension(uri); - addExtractorsForFormat(extensionFormat, extractors); + @FileTypes.Type int inferredFileType = inferFileTypeFromUri(uri); + addExtractorsForFormat(inferredFileType, extractors); for (int format : DEFAULT_EXTRACTOR_ORDER) { - if (format != extensionFormat) { + if (format != inferredFileType) { addExtractorsForFormat(format, extractors); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 32a156f66d..52d9e359cd 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.source.hls; -import static com.google.android.exoplayer2.util.FileTypes.getFormatFromExtension; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; import android.net.Uri; import android.text.TextUtils; @@ -101,12 +101,12 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { // Try selecting the extractor by the file extension. @Nullable - Extractor extractorByFileExtension = - createExtractorByFileExtension(uri, format, muxedCaptionFormats, timestampAdjuster); + Extractor inferredExtractor = + createInferredExtractor( + uri, format, muxedCaptionFormats, timestampAdjuster, responseHeaders); extractorInput.resetPeekPosition(); - if (extractorByFileExtension != null - && sniffQuietly(extractorByFileExtension, extractorInput)) { - return buildResult(extractorByFileExtension); + if (inferredExtractor != null && sniffQuietly(inferredExtractor, extractorInput)) { + return buildResult(inferredExtractor); } // We need to manually sniff each known type, without retrying the one selected by file @@ -114,9 +114,9 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. // Extractor to be used if the type is not recognized. - @Nullable Extractor fallBackExtractor = extractorByFileExtension; + @Nullable Extractor fallBackExtractor = inferredExtractor; - if (!(extractorByFileExtension instanceof FragmentedMp4Extractor)) { + if (!(inferredExtractor instanceof FragmentedMp4Extractor)) { FragmentedMp4Extractor fragmentedMp4Extractor = createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); if (sniffQuietly(fragmentedMp4Extractor, extractorInput)) { @@ -124,14 +124,14 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } } - if (!(extractorByFileExtension instanceof WebvttExtractor)) { + if (!(inferredExtractor instanceof WebvttExtractor)) { WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster); if (sniffQuietly(webvttExtractor, extractorInput)) { return buildResult(webvttExtractor); } } - if (!(extractorByFileExtension instanceof TsExtractor)) { + if (!(inferredExtractor instanceof TsExtractor)) { TsExtractor tsExtractor = createTsExtractor( payloadReaderFactoryFlags, @@ -147,28 +147,28 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } } - if (!(extractorByFileExtension instanceof AdtsExtractor)) { + if (!(inferredExtractor instanceof AdtsExtractor)) { AdtsExtractor adtsExtractor = new AdtsExtractor(); if (sniffQuietly(adtsExtractor, extractorInput)) { return buildResult(adtsExtractor); } } - if (!(extractorByFileExtension instanceof Ac3Extractor)) { + if (!(inferredExtractor instanceof Ac3Extractor)) { Ac3Extractor ac3Extractor = new Ac3Extractor(); if (sniffQuietly(ac3Extractor, extractorInput)) { return buildResult(ac3Extractor); } } - if (!(extractorByFileExtension instanceof Ac4Extractor)) { + if (!(inferredExtractor instanceof Ac4Extractor)) { Ac4Extractor ac4Extractor = new Ac4Extractor(); if (sniffQuietly(ac4Extractor, extractorInput)) { return buildResult(ac4Extractor); } } - if (!(extractorByFileExtension instanceof Mp3Extractor)) { + if (!(inferredExtractor instanceof Mp3Extractor)) { Mp3Extractor mp3Extractor = new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); if (sniffQuietly(mp3Extractor, extractorInput)) { @@ -180,16 +180,20 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } @Nullable - private Extractor createExtractorByFileExtension( + private Extractor createInferredExtractor( Uri uri, Format format, @Nullable List muxedCaptionFormats, - TimestampAdjuster timestampAdjuster) { - if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType)) { - return new WebvttExtractor(format.language, timestampAdjuster); + TimestampAdjuster timestampAdjuster, + Map> responseHeaders) { + @FileTypes.Type int fileType = FileTypes.inferFileTypeFromMimeType(format.sampleMimeType); + if (fileType == FileTypes.UNKNOWN) { + fileType = FileTypes.inferFileTypeFromResponseHeaders(responseHeaders); } - @FileTypes.Type int fileFormat = getFormatFromExtension(uri); - switch (fileFormat) { + if (fileType == FileTypes.UNKNOWN) { + fileType = inferFileTypeFromUri(uri); + } + switch (fileType) { case FileTypes.WEBVTT: return new WebvttExtractor(format.language, timestampAdjuster); case FileTypes.ADTS: From 6026b919a143ad870cfad3a9d84a260452ebc43b Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Tue, 9 Jun 2020 12:58:53 +0100 Subject: [PATCH 0438/1052] Fix test --- .../com/google/android/exoplayer2/testutil/TestUtilTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java index 0a999c4161..379f3ebd62 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java @@ -41,6 +41,6 @@ public class TestUtilTest { long startTimeMs = System.currentTimeMillis(); assertThat(conditionVariable.block(/* timeoutMs= */ 500)).isFalse(); long endTimeMs = System.currentTimeMillis(); - assertThat(endTimeMs - startTimeMs).isAtLeast(500); + assertThat(endTimeMs - startTimeMs).isAtLeast(500L); } } From 83758577e3b6bc0c6cca62d42efbc1df9817cb7c Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 8 Jun 2020 20:57:04 +0100 Subject: [PATCH 0439/1052] Temporary fix for gapless regression for MP3. PiperOrigin-RevId: 315334491 --- .../exoplayer2/mediacodec/MediaCodecRenderer.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index c6fb5c2a0d..53875b6018 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -1391,8 +1391,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { formatQueue.add(presentationTimeUs, inputFormat); waitingForFirstSampleInFormat = false; } - largestQueuedPresentationTimeUs = Math.max(largestQueuedPresentationTimeUs, presentationTimeUs); + // TODO(b/158483277): Find the root cause of why a gap is introduced in MP3 playback when using + // presentationTimeUs from the c2Mp3TimestampTracker. + if (c2Mp3TimestampTracker != null) { + largestQueuedPresentationTimeUs = Math.max(largestQueuedPresentationTimeUs, buffer.timeUs); + } else { + largestQueuedPresentationTimeUs = + Math.max(largestQueuedPresentationTimeUs, presentationTimeUs); + } buffer.flip(); if (buffer.hasSupplementalData()) { handleInputBufferSupplementalData(buffer); From a56a02d2c52e880953bf343ab173547f1da39206 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 9 Jun 2020 08:02:53 +0100 Subject: [PATCH 0440/1052] Add tests for resuming ad playbacks This is in preparation for refactoring the logic to support not playing an ad before the resume position (optionally). PiperOrigin-RevId: 315431483 --- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 6815bcf870..d92417c2de 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -17,10 +17,12 @@ package com.google.android.exoplayer2.ext.ima; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -323,6 +325,134 @@ public final class ImaAdsLoaderTest { .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } + @Test + public void resumePlaybackBeforeMidroll_playsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); From d23ca7b11afaa28ab87e57fc03be1169d2edb319 Mon Sep 17 00:00:00 2001 From: christosts Date: Tue, 9 Jun 2020 13:53:14 +0100 Subject: [PATCH 0441/1052] Set MediaCodec operation mode for audio/video Add experimental APIs to set the MediaCodecOperationMode separately for audio and video. PiperOrigin-RevId: 315467157 --- .../exoplayer2/DefaultRenderersFactory.java | 45 ++++++++++++++++--- .../mediacodec/MediaCodecRenderer.java | 2 + 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index 0b74d1175c..bd56974b32 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -90,7 +90,8 @@ public class DefaultRenderersFactory implements RenderersFactory { private long allowedVideoJoiningTimeMs; private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; - private @MediaCodecRenderer.MediaCodecOperationMode int mediaCodecOperationMode; + private @MediaCodecRenderer.MediaCodecOperationMode int audioMediaCodecOperationMode; + private @MediaCodecRenderer.MediaCodecOperationMode int videoMediaCodecOperationMode; private boolean enableOffload; /** @param context A {@link Context}. */ @@ -99,7 +100,8 @@ public class DefaultRenderersFactory implements RenderersFactory { extensionRendererMode = EXTENSION_RENDERER_MODE_OFF; allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; mediaCodecSelector = MediaCodecSelector.DEFAULT; - mediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; + audioMediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; + videoMediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; } /** @@ -145,7 +147,7 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecRenderer} + * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecAudioRenderer} * instances. * *

      This method is experimental, and will be renamed or removed in a future release. @@ -153,9 +155,40 @@ public class DefaultRenderersFactory implements RenderersFactory { * @param mode The {@link MediaCodecRenderer.MediaCodecOperationMode} to set. * @return This factory, for convenience. */ + public DefaultRenderersFactory experimental_setAudioMediaCodecOperationMode( + @MediaCodecRenderer.MediaCodecOperationMode int mode) { + audioMediaCodecOperationMode = mode; + return this; + } + + /** + * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecVideoRenderer} + * instances. + * + *

      This method is experimental, and will be renamed or removed in a future release. + * + * @param mode The {@link MediaCodecRenderer.MediaCodecOperationMode} to set. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory experimental_setVideoMediaCodecOperationMode( + @MediaCodecRenderer.MediaCodecOperationMode int mode) { + videoMediaCodecOperationMode = mode; + return this; + } + + /** + * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} for both {@link + * MediaCodecAudioRenderer} {@link MediaCodecVideoRenderer} instances. + * + *

      This method is experimental, and will be renamed or removed in a future release. + * + * @param mode The {@link MediaCodecRenderer.MediaCodecOperationMode} to set. + * @return This factory, for convenience. + */ public DefaultRenderersFactory experimental_setMediaCodecOperationMode( @MediaCodecRenderer.MediaCodecOperationMode int mode) { - mediaCodecOperationMode = mode; + experimental_setAudioMediaCodecOperationMode(mode); + experimental_setVideoMediaCodecOperationMode(mode); return this; } @@ -283,7 +316,7 @@ public class DefaultRenderersFactory implements RenderersFactory { eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - videoRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); + videoRenderer.experimental_setMediaCodecOperationMode(videoMediaCodecOperationMode); out.add(videoRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -415,7 +448,7 @@ public class DefaultRenderersFactory implements RenderersFactory { new DefaultAudioProcessorChain(audioProcessors), /* enableFloatOutput= */ false, enableOffload)); - audioRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); + audioRenderer.experimental_setMediaCodecOperationMode(audioMediaCodecOperationMode); out.add(audioRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 53875b6018..b85fbe3a71 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -76,6 +76,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

    1. {@link #OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD} *
    2. {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} *
    3. {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK} + *
    4. {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING} + *
    5. {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING} * */ @Documented From 947485e2b7f8ccb9bca6dce4ebc6ab03a4511cdf Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 9 Jun 2020 15:01:33 +0100 Subject: [PATCH 0442/1052] CEA-608: Position top-of-screen roll-up cues with bottom-line=row Reasoning and screenshots here: https://github.com/google/ExoPlayer/issues/7475#issuecomment-640770791 Issue: #7475 PiperOrigin-RevId: 315475888 --- RELEASENOTES.md | 4 ++-- .../android/exoplayer2/text/cea/Cea608Decoder.java | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3f451c8405..bc6dd6a75f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -122,8 +122,8 @@ * Ignore excess characters in CEA-608 lines (max length is 32) ([#7341](https://github.com/google/ExoPlayer/issues/7341)). * Add support for WebVTT's `ruby-position` CSS property. - * Don't assume CEA-608 captions are in the bottom half of the - screen([#7475](https://github.com/google/ExoPlayer/issues/7475)). + * Fix positioning for CEA-608 roll-up captions in the top half of screen + ([#7475](https://github.com/google/ExoPlayer/issues/7475)). * DRM: * Add support for attaching DRM sessions to clear content in the demo app. * Remove `DrmSessionManager` references from all renderers. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 1dfbff2598..1990cde9c2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -946,7 +946,7 @@ public final class Cea608Decoder extends CeaDecoder { int lineAnchor; int line; - // Note: Row indices are in the range [1-15]. + // Note: Row indices are in the range [1-15], Cue.line counts from 0 (top) and -1 (bottom). if (row > (BASE_ROW / 2)) { lineAnchor = Cue.ANCHOR_TYPE_END; line = row - BASE_ROW; @@ -955,9 +955,12 @@ public final class Cea608Decoder extends CeaDecoder { line -= 2; } else { lineAnchor = Cue.ANCHOR_TYPE_START; - // Line indices from the top of the window start from 0, but we want a blank row to act as - // the safe area. As a result no adjustment is necessary. - line = row; + // The `row` of roll-up cues positions the bottom line (even for cues shown in the top + // half of the screen), so we need to consider the number of rows in this cue. In + // non-roll-up, we don't need any further adjustments because we leave the first line + // (cue.line=0) blank to act as the safe area, so positioning row=1 at Cue.line=1 is + // correct. + line = captionMode == CC_MODE_ROLL_UP ? row - (captionRowCount - 1) : row; } return new Cue( From a11f7b8cdd01ba038b8b30c05acfbecba0214f2a Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Tue, 9 Jun 2020 15:31:06 +0100 Subject: [PATCH 0443/1052] Document DataSource.getResponseHeaders case-insensitivity PiperOrigin-RevId: 315480048 --- .../java/com/google/android/exoplayer2/upstream/DataSource.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java index 9a321fbdd8..bbc182d7af 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java @@ -74,6 +74,8 @@ public interface DataSource extends DataReader { /** * When the source is open, returns the response headers associated with the last {@link #open} * call. Otherwise, returns an empty map. + * + *

      Key look-up in the returned map is case-insensitive. */ default Map> getResponseHeaders() { return Collections.emptyMap(); From c759b5b1a9a2b74627111f1df6184c5e40b51a4f Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 9 Jun 2020 15:36:28 +0100 Subject: [PATCH 0444/1052] Add option to hide Prev/Rwnd/Ffwd/Next buttons Issue: #7410 PiperOrigin-RevId: 315480798 --- RELEASENOTES.md | 16 +-- .../exoplayer2/ui/PlayerControlView.java | 101 +++++++++++++++--- .../android/exoplayer2/ui/PlayerView.java | 40 +++++++ library/ui/src/main/res/values/attrs.xml | 8 ++ 4 files changed, 147 insertions(+), 18 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bc6dd6a75f..d19381bf3f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -184,6 +184,9 @@ * UI * Remove deperecated `exo_simple_player_view.xml` and `exo_playback_control_view.xml` from resource. + * Add setter methods to `PlayerView` and `PlayerControlView` to set + whether the rewind, fast forward, previous and next buttons are shown + ([#7410](https://github.com/google/ExoPlayer/issues/7410)). * Move logic of prev, next, fast forward and rewind to ControlDispatcher ([#6926](https://github.com/google/ExoPlayer/issues/6926)). * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog @@ -211,10 +214,10 @@ ([#7306](https://github.com/google/ExoPlayer/issues/7306)). * Fix issue in `AudioTrackPositionTracker` that could cause negative positions to be reported at the start of playback and immediately after seeking - ([#7456](https://github.com/google/ExoPlayer/issues/7456). + ([#7456](https://github.com/google/ExoPlayer/issues/7456)). * Fix further cases where downloads would sometimes not resume after their network requirements are met - ([#7453](https://github.com/google/ExoPlayer/issues/7453). + ([#7453](https://github.com/google/ExoPlayer/issues/7453)). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as @@ -295,11 +298,12 @@ to the `DefaultAudioSink` constructor ([#7134](https://github.com/google/ExoPlayer/issues/7134)). * Workaround issue that could cause slower than realtime playback of AAC - on Android 10 ([#6671](https://github.com/google/ExoPlayer/issues/6671). + on Android 10 + ([#6671](https://github.com/google/ExoPlayer/issues/6671)). * Fix case where another app spuriously holding transient audio focus could prevent ExoPlayer from acquiring audio focus for an indefinite period of time - ([#7182](https://github.com/google/ExoPlayer/issues/7182). + ([#7182](https://github.com/google/ExoPlayer/issues/7182)). * Fix case where the player volume could be permanently ducked if audio focus was released whilst ducking. * Fix playback of WAV files with trailing non-media bytes @@ -1248,7 +1252,7 @@ ([#4492](https://github.com/google/ExoPlayer/issues/4492) and [#4634](https://github.com/google/ExoPlayer/issues/4634)). * Fix issue where removing looping media from a playlist throws an exception - ([#4871](https://github.com/google/ExoPlayer/issues/4871). + ([#4871](https://github.com/google/ExoPlayer/issues/4871)). * Fix issue where the preferred audio or text track would not be selected if mapped onto a secondary renderer of the corresponding type ([#4711](http://github.com/google/ExoPlayer/issues/4711)). @@ -1679,7 +1683,7 @@ resources when the playback thread has quit by the time the loading task has completed. * ID3: Better handle malformed ID3 data - ([#3792](https://github.com/google/ExoPlayer/issues/3792). + ([#3792](https://github.com/google/ExoPlayer/issues/3792)). * Support 14-bit mode and little endianness in DTS PES packets ([#3340](https://github.com/google/ExoPlayer/issues/3340)). * Demo app: Add ability to download not DRM protected content. diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java index 778f033f0c..4b9de374c3 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java @@ -66,6 +66,26 @@ import java.util.concurrent.CopyOnWriteArrayList; *

    6. Corresponding method: {@link #setShowTimeoutMs(int)} *
    7. Default: {@link #DEFAULT_SHOW_TIMEOUT_MS} * + *
    8. {@code show_rewind_button} - Whether the rewind button is shown. + *
        + *
      • Corresponding method: {@link #setShowRewindButton(boolean)} + *
      • Default: true + *
      + *
    9. {@code show_fastforward_button} - Whether the fast forward button is shown. + *
        + *
      • Corresponding method: {@link #setShowFastForwardButton(boolean)} + *
      • Default: true + *
      + *
    10. {@code show_previous_button} - Whether the previous button is shown. + *
        + *
      • Corresponding method: {@link #setShowPreviousButton(boolean)} + *
      • Default: true + *
      + *
    11. {@code show_next_button} - Whether the next button is shown. + *
        + *
      • Corresponding method: {@link #setShowNextButton(boolean)} + *
      • Default: true + *
      *
    12. {@code rewind_increment} - The duration of the rewind applied when the user taps the * rewind button, in milliseconds. Use zero to disable the rewind button. *
        @@ -305,6 +325,10 @@ public class PlayerControlView extends FrameLayout { private int showTimeoutMs; private int timeBarMinUpdateIntervalMs; private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes; + private boolean showRewindButton; + private boolean showFastForwardButton; + private boolean showPreviousButton; + private boolean showNextButton; private boolean showShuffleButton; private long hideAtMs; private long[] adGroupTimesMs; @@ -341,6 +365,10 @@ public class PlayerControlView extends FrameLayout { repeatToggleModes = DEFAULT_REPEAT_TOGGLE_MODES; timeBarMinUpdateIntervalMs = DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS; hideAtMs = C.TIME_UNSET; + showRewindButton = true; + showFastForwardButton = true; + showPreviousButton = true; + showNextButton = true; showShuffleButton = false; int rewindMs = DefaultControlDispatcher.DEFAULT_REWIND_MS; int fastForwardMs = DefaultControlDispatcher.DEFAULT_FAST_FORWARD_MS; @@ -357,6 +385,15 @@ public class PlayerControlView extends FrameLayout { controllerLayoutId = a.getResourceId(R.styleable.PlayerControlView_controller_layout_id, controllerLayoutId); repeatToggleModes = getRepeatToggleModes(a, repeatToggleModes); + showRewindButton = + a.getBoolean(R.styleable.PlayerControlView_show_rewind_button, showRewindButton); + showFastForwardButton = + a.getBoolean( + R.styleable.PlayerControlView_show_fastforward_button, showFastForwardButton); + showPreviousButton = + a.getBoolean(R.styleable.PlayerControlView_show_previous_button, showPreviousButton); + showNextButton = + a.getBoolean(R.styleable.PlayerControlView_show_next_button, showNextButton); showShuffleButton = a.getBoolean(R.styleable.PlayerControlView_show_shuffle_button, showShuffleButton); setTimeBarMinUpdateInterval( @@ -592,6 +629,46 @@ public class PlayerControlView extends FrameLayout { } } + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + this.showRewindButton = showRewindButton; + updateNavigation(); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + this.showFastForwardButton = showFastForwardButton; + updateNavigation(); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + this.showPreviousButton = showPreviousButton; + updateNavigation(); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + this.showNextButton = showNextButton; + updateNavigation(); + } + /** * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. @@ -832,10 +909,10 @@ public class PlayerControlView extends FrameLayout { } } - setButtonEnabled(enablePrevious, previousButton); - setButtonEnabled(enableRewind, rewindButton); - setButtonEnabled(enableFastForward, fastForwardButton); - setButtonEnabled(enableNext, nextButton); + updateButton(showPreviousButton, enablePrevious, previousButton); + updateButton(showRewindButton, enableRewind, rewindButton); + updateButton(showFastForwardButton, enableFastForward, fastForwardButton); + updateButton(showNextButton, enableNext, nextButton); if (timeBar != null) { timeBar.setEnabled(enableSeeking); } @@ -847,19 +924,19 @@ public class PlayerControlView extends FrameLayout { } if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE) { - repeatToggleButton.setVisibility(GONE); + updateButton(/* visible= */ false, /* enabled= */ false, repeatToggleButton); return; } @Nullable Player player = this.player; if (player == null) { - setButtonEnabled(false, repeatToggleButton); + updateButton(/* visible= */ true, /* enabled= */ false, repeatToggleButton); repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); repeatToggleButton.setContentDescription(repeatOffButtonContentDescription); return; } - setButtonEnabled(true, repeatToggleButton); + updateButton(/* visible= */ true, /* enabled= */ true, repeatToggleButton); switch (player.getRepeatMode()) { case Player.REPEAT_MODE_OFF: repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); @@ -886,13 +963,13 @@ public class PlayerControlView extends FrameLayout { @Nullable Player player = this.player; if (!showShuffleButton) { - shuffleButton.setVisibility(GONE); + updateButton(/* visible= */ false, /* enabled= */ false, shuffleButton); } else if (player == null) { - setButtonEnabled(false, shuffleButton); + updateButton(/* visible= */ true, /* enabled= */ false, shuffleButton); shuffleButton.setImageDrawable(shuffleOffButtonDrawable); shuffleButton.setContentDescription(shuffleOffContentDescription); } else { - setButtonEnabled(true, shuffleButton); + updateButton(/* visible= */ true, /* enabled= */ true, shuffleButton); shuffleButton.setImageDrawable( player.getShuffleModeEnabled() ? shuffleOnButtonDrawable : shuffleOffButtonDrawable); shuffleButton.setContentDescription( @@ -1029,13 +1106,13 @@ public class PlayerControlView extends FrameLayout { } } - private void setButtonEnabled(boolean enabled, @Nullable View view) { + private void updateButton(boolean visible, boolean enabled, @Nullable View view) { if (view == null) { return; } view.setEnabled(enabled); view.setAlpha(enabled ? buttonAlphaEnabled : buttonAlphaDisabled); - view.setVisibility(VISIBLE); + view.setVisibility(visible ? VISIBLE : GONE); } private void seekToTimeBarPosition(Player player, long positionMs) { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java index 6ee2e3f6a4..a307bd5fd2 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java @@ -1000,6 +1000,46 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider controller.setControlDispatcher(controlDispatcher); } + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + Assertions.checkStateNotNull(controller); + controller.setShowRewindButton(showRewindButton); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + Assertions.checkStateNotNull(controller); + controller.setShowFastForwardButton(showFastForwardButton); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + Assertions.checkStateNotNull(controller); + controller.setShowPreviousButton(showPreviousButton); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + Assertions.checkStateNotNull(controller); + controller.setShowNextButton(showNextButton); + } + /** * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. diff --git a/library/ui/src/main/res/values/attrs.xml b/library/ui/src/main/res/values/attrs.xml index e0a6b7faf4..b064a71b3c 100644 --- a/library/ui/src/main/res/values/attrs.xml +++ b/library/ui/src/main/res/values/attrs.xml @@ -44,6 +44,10 @@ + + + + @@ -115,6 +119,10 @@ + + + + From 1f17756ad2cb4f427bac57de8d0911147307cd06 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 9 Jun 2020 16:12:36 +0100 Subject: [PATCH 0445/1052] Optimize DefaultExtractorsFactory order using MIME types PiperOrigin-RevId: 315485985 --- RELEASENOTES.md | 4 +-- .../source/BundledExtractorsAdapter.java | 19 ++++++++++---- .../source/ProgressiveMediaExtractor.java | 11 +++++++- .../source/ProgressiveMediaPeriod.java | 15 ++++++++--- .../source/ProgressiveMediaSource.java | 2 +- .../extractor/DefaultExtractorsFactory.java | 23 ++++++++++++---- .../extractor/ExtractorsFactory.java | 12 ++++++--- .../DefaultExtractorsFactoryTest.java | 26 +++++++++++++------ 8 files changed, 83 insertions(+), 29 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d19381bf3f..a7ad147963 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -174,8 +174,8 @@ * Change the order of extractors for sniffing to reduce start-up latency in `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` ([#6410](https://github.com/google/ExoPlayer/issues/6410)). - * Select first extractors based on the filename extension in - `DefaultExtractorsFactory`. + * Select first extractors based on the filename extension and the response + headers mime type in `DefaultExtractorsFactory`. * Testing * Add `TestExoPlayer`, a utility class with APIs to create `SimpleExoPlayer` instances with fake components for testing. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java index 4b487bfbdf..7e770d4e39 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; import com.google.android.exoplayer2.upstream.DataReader; @@ -29,6 +30,8 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; +import java.util.List; +import java.util.Map; /** * {@link ProgressiveMediaExtractor} built on top of {@link Extractor} instances, whose @@ -36,7 +39,7 @@ import java.io.IOException; */ /* package */ final class BundledExtractorsAdapter implements ProgressiveMediaExtractor { - private final Extractor[] extractors; + private final ExtractorsFactory extractorsFactory; @Nullable private Extractor extractor; @Nullable private ExtractorInput extractorInput; @@ -44,21 +47,27 @@ import java.io.IOException; /** * Creates a holder that will select an extractor and initialize it using the specified output. * - * @param extractors One or more extractors to choose from. + * @param extractorsFactory The {@link ExtractorsFactory} providing the extractors to choose from. */ - public BundledExtractorsAdapter(Extractor[] extractors) { - this.extractors = extractors; + public BundledExtractorsAdapter(ExtractorsFactory extractorsFactory) { + this.extractorsFactory = extractorsFactory; } @Override public void init( - DataReader dataReader, Uri uri, long position, long length, ExtractorOutput output) + DataReader dataReader, + Uri uri, + Map> responseHeaders, + long position, + long length, + ExtractorOutput output) throws IOException { ExtractorInput extractorInput = new DefaultExtractorInput(dataReader, position, length); this.extractorInput = extractorInput; if (extractor != null) { return; } + Extractor[] extractors = extractorsFactory.createExtractors(uri, responseHeaders); if (extractors.length == 1) { this.extractor = extractors[0]; } else { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java index 6cc7c91232..9efe6acba1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java @@ -22,6 +22,8 @@ import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.upstream.DataReader; import java.io.IOException; +import java.util.List; +import java.util.Map; /** Extracts the contents of a container file from a progressive media stream. */ /* package */ interface ProgressiveMediaExtractor { @@ -31,6 +33,7 @@ import java.io.IOException; * * @param dataReader The {@link DataReader} from which data should be read. * @param uri The {@link Uri} from which the media is obtained. + * @param responseHeaders The response headers of the media, or an empty map if there are none. * @param position The initial position of the {@code dataReader} in the stream. * @param length The length of the stream, or {@link C#LENGTH_UNSET} if length is unknown. * @param output The {@link ExtractorOutput} that will be used to initialize the selected @@ -38,7 +41,13 @@ import java.io.IOException; * @throws UnrecognizedInputFormatException Thrown if the input format could not be detected. * @throws IOException Thrown if the input could not be read. */ - void init(DataReader dataReader, Uri uri, long position, long length, ExtractorOutput output) + void init( + DataReader dataReader, + Uri uri, + Map> responseHeaders, + long position, + long length, + ExtractorOutput output) throws IOException; /** Releases any held resources. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 0f5d2ce344..1e48d95bee 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekMap.SeekPoints; @@ -143,7 +144,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * @param uri The {@link Uri} of the media stream. * @param dataSource The data source to read the media. - * @param extractors The extractors to use to read the data source. + * @param extractorsFactory The {@link ExtractorsFactory} to use to read the data source. * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. * @param eventDispatcher A dispatcher to notify of events. * @param listener A listener to notify when information about the period changes. @@ -161,7 +162,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public ProgressiveMediaPeriod( Uri uri, DataSource dataSource, - Extractor[] extractors, + ExtractorsFactory extractorsFactory, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, EventDispatcher eventDispatcher, @@ -179,7 +180,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; this.customCacheKey = customCacheKey; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; loader = new Loader("Loader:ProgressiveMediaPeriod"); - ProgressiveMediaExtractor progressiveMediaExtractor = new BundledExtractorsAdapter(extractors); + ProgressiveMediaExtractor progressiveMediaExtractor = + new BundledExtractorsAdapter(extractorsFactory); this.progressiveMediaExtractor = progressiveMediaExtractor; loadCondition = new ConditionVariable(); maybeFinishPrepareRunnable = this::maybeFinishPrepare; @@ -1022,7 +1024,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; icyTrackOutput.format(ICY_FORMAT); } progressiveMediaExtractor.init( - extractorDataSource, uri, position, length, extractorOutput); + extractorDataSource, + uri, + dataSource.getResponseHeaders(), + position, + length, + extractorOutput); if (icyHeaders != null) { progressiveMediaExtractor.disableSeekingOnMp3Streams(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index fbb657a4e4..bfec295568 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -276,7 +276,7 @@ public final class ProgressiveMediaSource extends BaseMediaSource return new ProgressiveMediaPeriod( playbackProperties.uri, dataSource, - extractorsFactory.createExtractors(playbackProperties.uri), + extractorsFactory, drmSessionManager, loadableLoadErrorHandlingPolicy, createEventDispatcher(id), diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 585871635c..0cf017b800 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.extractor; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromResponseHeaders; import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; import android.net.Uri; @@ -39,7 +40,9 @@ import com.google.android.exoplayer2.util.FileTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.lang.reflect.Constructor; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * An {@link ExtractorsFactory} that provides an array of extractors for the following formats: @@ -265,18 +268,28 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { @Override public synchronized Extractor[] createExtractors() { - return createExtractors(Uri.EMPTY); + return createExtractors(Uri.EMPTY, new HashMap<>()); } @Override - public synchronized Extractor[] createExtractors(Uri uri) { + public synchronized Extractor[] createExtractors( + Uri uri, Map> responseHeaders) { List extractors = new ArrayList<>(/* initialCapacity= */ 14); - @FileTypes.Type int inferredFileType = inferFileTypeFromUri(uri); - addExtractorsForFormat(inferredFileType, extractors); + @FileTypes.Type + int responseHeadersInferredFileType = inferFileTypeFromResponseHeaders(responseHeaders); + if (responseHeadersInferredFileType != FileTypes.UNKNOWN) { + addExtractorsForFormat(responseHeadersInferredFileType, extractors); + } + + @FileTypes.Type int uriInferredFileType = inferFileTypeFromUri(uri); + if (uriInferredFileType != FileTypes.UNKNOWN + && uriInferredFileType != responseHeadersInferredFileType) { + addExtractorsForFormat(uriInferredFileType, extractors); + } for (int format : DEFAULT_EXTRACTOR_ORDER) { - if (format != inferredFileType) { + if (format != responseHeadersInferredFileType && format != uriInferredFileType) { addExtractorsForFormat(format, extractors); } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java index bbd43d8c5a..d077b1b11e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.extractor; import android.net.Uri; +import java.util.List; +import java.util.Map; /** Factory for arrays of {@link Extractor} instances. */ public interface ExtractorsFactory { @@ -24,10 +26,14 @@ public interface ExtractorsFactory { Extractor[] createExtractors(); /** - * Returns an array of new {@link Extractor} instances to extract the stream corresponding to the - * provided {@link Uri}. + * Returns an array of new {@link Extractor} instances. + * + * @param uri The {@link Uri} of the media to extract. + * @param responseHeaders The response headers of the media to extract, or an empty map if there + * are none. The map lookup should be case-insensitive. + * @return The {@link Extractor} instances. */ - default Extractor[] createExtractors(Uri uri) { + default Extractor[] createExtractors(Uri uri, Map> responseHeaders) { return createExtractors(); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java index 030c45e5b1..ba10f56a51 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java @@ -33,8 +33,12 @@ import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.PsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.extractor.wav.WavExtractor; +import com.google.android.exoplayer2.util.MimeTypes; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,7 +47,7 @@ import org.junit.runner.RunWith; public final class DefaultExtractorsFactoryTest { @Test - public void createExtractors_withoutUri_optimizesSniffingOrder() { + public void createExtractors_withoutMediaInfo_optimizesSniffingOrder() { DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); Extractor[] extractors = defaultExtractorsFactory.createExtractors(); @@ -69,24 +73,31 @@ public final class DefaultExtractorsFactoryTest { } @Test - public void createExtractors_withUri_startsWithExtractorsMatchingExtension() { + public void createExtractors_withMediaInfo_startsWithExtractorsMatchingHeadersAndThenUri() { DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + Uri uri = Uri.parse("test.mp3"); + Map> responseHeaders = new HashMap<>(); + responseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.VIDEO_MP4)); - Extractor[] extractors = defaultExtractorsFactory.createExtractors(Uri.parse("test.mp4")); + Extractor[] extractors = defaultExtractorsFactory.createExtractors(uri, responseHeaders); List> extractorClasses = getExtractorClasses(extractors); assertThat(extractorClasses.subList(0, 2)) .containsExactly(Mp4Extractor.class, FragmentedMp4Extractor.class); + assertThat(extractorClasses.get(2)).isEqualTo(Mp3Extractor.class); } @Test - public void createExtractors_withUri_optimizesSniffingOrder() { + public void createExtractors_withMediaInfo_optimizesSniffingOrder() { DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + Uri uri = Uri.parse("test.mp3"); + Map> responseHeaders = new HashMap<>(); + responseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.VIDEO_MP4)); - Extractor[] extractors = defaultExtractorsFactory.createExtractors(Uri.parse("test.mp4")); + Extractor[] extractors = defaultExtractorsFactory.createExtractors(uri, responseHeaders); List> extractorClasses = getExtractorClasses(extractors); - assertThat(extractorClasses.subList(2, extractors.length)) + assertThat(extractorClasses.subList(3, extractors.length)) .containsExactly( FlvExtractor.class, FlacExtractor.class, @@ -98,8 +109,7 @@ public final class DefaultExtractorsFactoryTest { MatroskaExtractor.class, AdtsExtractor.class, Ac3Extractor.class, - Ac4Extractor.class, - Mp3Extractor.class) + Ac4Extractor.class) .inOrder(); } From 9ef9b56bcd9db01c493f7c28b53947151d632704 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 9 Jun 2020 16:31:30 +0100 Subject: [PATCH 0446/1052] Separate ads rendering and AdsManager init In a later change it will be necessary to be able to destroy the ads manager if all ads are skipped while creating ads rendering settings. This change prepares for doing that by not having the ads manager passed into the method (so the caller can null or initialize it). PiperOrigin-RevId: 315488830 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index ffccaa08d9..51c3894a19 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -383,7 +383,7 @@ public final class ImaAdsLoader private int lastVolumePercentage; @Nullable private AdsManager adsManager; - private boolean initializedAdsManager; + private boolean isAdsManagerInitialized; private boolean hasAdPlaybackState; @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; @@ -980,9 +980,16 @@ public final class ImaAdsLoader if (contentDurationUs != C.TIME_UNSET) { adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); } - if (!initializedAdsManager && adsManager != null) { - initializedAdsManager = true; - initializeAdsManager(adsManager); + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + adsManager.init(adsRenderingSettings); + adsManager.start(); + updateAdPlaybackState(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } } handleTimelineOrPositionChanged(); } @@ -1056,7 +1063,8 @@ public final class ImaAdsLoader // Internal methods. - private void initializeAdsManager(AdsManager adsManager) { + /** Configures ads rendering for starting playback, returning the settings for the IMA SDK. */ + private AdsRenderingSettings setupAdsRendering() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes(supportedMimeTypes); @@ -1072,36 +1080,32 @@ public final class ImaAdsLoader } // Skip ads based on the start position as required. - long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; long contentPositionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); int adGroupIndexForPosition = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + if (adGroupIndexForPosition != C.INDEX_UNSET) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be cleared. + if (hasMidrollAdGroups(adGroupTimesUs)) { + pendingContentPositionMs = contentPositionMs; + } + if (adGroupIndexForPosition > 0) { + // Skip any ad groups before the one at or immediately before the playback position. + for (int i = 0; i < adGroupIndexForPosition; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + // Play ads after the midpoint between the ad to play and the one before it, to avoid issues + // with rounding one of the two ad times. + long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; + long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); } - // Play ads after the midpoint between the ad to play and the one before it, to avoid issues - // with rounding one of the two ad times. - long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; - long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; - double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; - adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); - } - - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { - // Provide the player's initial position to trigger loading and playing the ad. - pendingContentPositionMs = contentPositionMs; - } - - adsManager.init(adsRenderingSettings); - adsManager.start(); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); } + return adsRenderingSettings; } private void handleAdEvent(AdEvent adEvent) { From 5aa8a7a5074cbbd13976bffec6b45e8b0b4a6a60 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 9 Jun 2020 18:37:00 +0100 Subject: [PATCH 0447/1052] Prevent shutter closing for within-window seeks to unprepared periods Issue: #5507 PiperOrigin-RevId: 315512207 --- RELEASENOTES.md | 4 +++ .../android/exoplayer2/ui/PlayerView.java | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a7ad147963..0286ccec23 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -191,6 +191,10 @@ ([#6926](https://github.com/google/ExoPlayer/issues/6926)). * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog ([#7357](https://github.com/google/ExoPlayer/issues/7357)). + * Prevent the video surface going black when seeking to an unprepared + period within the current window. For example when seeking over an ad + group, or to the next period in a multi-period DASH stream + ([#5507](https://github.com/google/ExoPlayer/issues/5507)). * Metadata: Add minimal DVB Application Information Table (AIT) support ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java index a307bd5fd2..985fbd3dda 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java @@ -48,6 +48,8 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.flac.PictureFrame; import com.google.android.exoplayer2.metadata.id3.ApicFrame; @@ -1554,6 +1556,13 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider SingleTapListener, PlayerControlView.VisibilityListener { + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + // TextOutput implementation @Override @@ -1602,6 +1611,29 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider @Override public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + Player player = Assertions.checkNotNull(PlayerView.this.player); + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + lastPeriodUidWithTracks = null; + } else if (!player.getCurrentTrackGroups().isEmpty()) { + lastPeriodUidWithTracks = + timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid; + } else if (lastPeriodUidWithTracks != null) { + int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks); + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + int lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex; + if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) { + // We're in the same window. Suppress the update. + return; + } + } + lastPeriodUidWithTracks = null; + } + updateForCurrentTrackSelections(/* isNewPlayer= */ false); } From 0caada8b8c19c1124b26a7b7be3c92041a736da7 Mon Sep 17 00:00:00 2001 From: krocard Date: Tue, 9 Jun 2020 18:58:24 +0100 Subject: [PATCH 0448/1052] Fix formating in javadoc PiperOrigin-RevId: 315516836 --- .../com/google/android/exoplayer2/util/MimeTypes.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index a3bc395574..47f8cc5fbc 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -475,11 +475,15 @@ public final class MimeTypes { * Retrieves the object type of an mp4 audio codec from its string as defined in RFC 6381. * *

        Per https://mp4ra.org/#/object_types and https://tools.ietf.org/html/rfc6381#section-3.3, an - * mp4 codec string has the form: + * mp4 codec string has the form: + * + *

            *         ~~~~~~~~~~~~~~ Object Type Indication (OTI) byte in hex
            *    mp4a.[a-zA-Z0-9]{2}(.[0-9]{1,2})?
            *                         ~~~~~~~~~~ audio OTI, decimal. Only for certain OTI.
        -   *  For example: mp4a.40.2, has an OTI of 0x40 and an audio OTI of 2.
        +   * 
        + * + * For example: mp4a.40.2, has an OTI of 0x40 and an audio OTI of 2. * * @param codec The string as defined in RFC 6381 describing an mp4 audio codec. * @return The {@link Mp4aObjectType} or {@code null} if the input is invalid. From b0457da038c3bf4a0b79ed6ac11ec35e58cf3b1f Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 10 Jun 2020 10:13:01 +0100 Subject: [PATCH 0449/1052] Simplify timestamp tracking An integer multiple/divide can be removed without loss of precision. PiperOrigin-RevId: 315653905 --- .../mediacodec/C2Mp3TimestampTracker.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java index a90ce89ebb..0c3fe9facf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java @@ -19,7 +19,9 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.MpegAudioUtil; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; +import java.nio.ByteBuffer; /** * Tracks the number of processed samples to calculate an accurate current timestamp, matching the @@ -55,17 +57,15 @@ import com.google.android.exoplayer2.util.Log; * @return The expected output presentation time, in microseconds. */ public long updateAndGetPresentationTimeUs(Format format, DecoderInputBuffer buffer) { - if (seenInvalidMpegAudioHeader || buffer.data == null) { + if (seenInvalidMpegAudioHeader) { return buffer.timeUs; } - // These calculations mirror the timestamp calculations in the Codec2 Mp3 Decoder. - // https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/codec2/components/mp3/C2SoftMp3Dec.cpp;l=464;drc=ed134640332fea70ca4b05694289d91a5265bb46 - long presentationTimeUs = processedSamples * C.MICROS_PER_SECOND / format.sampleRate; + ByteBuffer data = Assertions.checkNotNull(buffer.data); int sampleHeaderData = 0; for (int i = 0; i < 4; i++) { sampleHeaderData <<= 8; - sampleHeaderData |= buffer.data.get(i) & 0xFF; + sampleHeaderData |= data.get(i) & 0xFF; } int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(sampleHeaderData); @@ -74,14 +74,20 @@ import com.google.android.exoplayer2.util.Log; Log.w(TAG, "MPEG audio header is invalid."); return buffer.timeUs; } - long outSize = frameCount * format.channelCount * 2L; - boolean isFirstSample = processedSamples == 0; - long outOffset = 0; - if (isFirstSample) { + + // These calculations mirror the timestamp calculations in the Codec2 Mp3 Decoder. + // https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/codec2/components/mp3/C2SoftMp3Dec.cpp;l=464;drc=ed134640332fea70ca4b05694289d91a5265bb46 + if (processedSamples == 0) { anchorTimestampUs = buffer.timeUs; - outOffset = DECODER_DELAY_SAMPLES; + processedSamples = frameCount - DECODER_DELAY_SAMPLES; + return anchorTimestampUs; } - processedSamples += (outSize / (format.channelCount * 2L)) - outOffset; - return anchorTimestampUs + presentationTimeUs; + long processedDurationUs = getProcessedDurationUs(format); + processedSamples += frameCount; + return anchorTimestampUs + processedDurationUs; + } + + private long getProcessedDurationUs(Format format) { + return processedSamples * C.MICROS_PER_SECOND / format.sampleRate; } } From b0d98a2e22d165faaee485aa3e71d19eda2d5006 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 10 Jun 2020 10:57:23 +0100 Subject: [PATCH 0450/1052] Find correct next chunk if previous one didn't finish loading. If the previous chunk didn't finish loading, we need to find the appropriate next chunk based on the current loading position (or the previous chunk's start time if not independent). PiperOrigin-RevId: 315658435 --- .../google/android/exoplayer2/source/hls/HlsChunkSource.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index 2dc3c0771a..21d1bc4d6b 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -505,9 +505,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /* stayInBounds= */ !playlistTracker.isLive() || previous == null) + mediaPlaylist.mediaSequence; } - // We ignore the case of previous not having loaded completely, in which case we load the next - // segment. - return previous.getNextChunkIndex(); + return previous.isLoadCompleted() ? previous.getNextChunkIndex() : previous.chunkIndex; } private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { From 2a9144fa569bc58831d2e0e448e3ca07c4b6c579 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 10 Jun 2020 11:04:51 +0100 Subject: [PATCH 0451/1052] Fix loadCompleted flag in MediaChunk implementations. This flag was always set even if the load was canceled and not completed. PiperOrigin-RevId: 315659262 --- .../android/exoplayer2/source/chunk/ContainerMediaChunk.java | 2 +- .../com/google/android/exoplayer2/source/hls/HlsMediaChunk.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index d0daaf0839..57e5687337 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -138,7 +138,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { } finally { Util.closeQuietly(dataSource); } - loadCompleted = true; + loadCompleted = !loadCanceled; } /** diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 85f30986ef..4e87e717bf 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -349,7 +349,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (!hasGapTag) { loadMedia(); } - loadCompleted = true; + loadCompleted = !loadCanceled; } } From 95b61eb835e884678daef6e938c4bb0175489c2d Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 10 Jun 2020 11:09:30 +0100 Subject: [PATCH 0452/1052] Split TrackSelection.evalauteQueueSize in discard and cancelation. The option to cancel ongoing loads as part of the queue size evalation was added recently. This split out the decision to a new method so that a TrackSelection implementation can independently cancel loads and discard upstream data. It also clarifies that evaluateQueueSize will only be called if there is no ongoing load. Issue: #2848 PiperOrigin-RevId: 315659735 --- RELEASENOTES.md | 3 + .../exoplayer2/source/MediaPeriod.java | 4 +- .../exoplayer2/source/SequenceableLoader.java | 4 +- .../exoplayer2/source/chunk/ChunkSource.java | 16 ++-- .../trackselection/TrackSelection.java | 78 +++++++++++++++---- .../exoplayer2/source/hls/HlsChunkSource.java | 18 +++++ .../source/hls/HlsSampleStreamWrapper.java | 21 +++-- 7 files changed, 106 insertions(+), 38 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0286ccec23..ce94193ffd 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -88,6 +88,9 @@ ([#7332](https://github.com/google/ExoPlayer/issues/7332)). * Add `HttpDataSource.InvalidResponseCodeException#responseBody` field ([#6853](https://github.com/google/ExoPlayer/issues/6853)). + * Add `TrackSelection.shouldCancelMediaChunkLoad` to check whether an + ongoing load should be canceled. Only supported by HLS streams so far. + ([#2848](https://github.com/google/ExoPlayer/issues/2848)). * Video: Pass frame rate hint to `Surface.setFrameRate` on Android R devices. * Text: * Parse `` and `` tags in WebVTT subtitles (rendering is coming diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java index 2e2cf9caba..39b207e264 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java @@ -239,8 +239,8 @@ public interface MediaPeriod extends SequenceableLoader { * *

        This method is only called after the period has been prepared. * - *

        A period may choose to discard buffered media so that it can be re-buffered in a different - * quality. + *

        A period may choose to discard buffered media or cancel ongoing loads so that media can be + * re-buffered in a different quality. * * @param positionUs The current playback position in microseconds. If playback of this period has * not yet started, the value will be the starting position in this period minus the duration diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java index 189c13ef0f..fb6af1136a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java @@ -66,8 +66,8 @@ public interface SequenceableLoader { /** * Re-evaluates the buffer given the playback position. * - *

        Re-evaluation may discard buffered media so that it can be re-buffered in a different - * quality. + *

        Re-evaluation may discard buffered media or cancel ongoing loads so that media can be + * re-buffered in a different quality. * * @param positionUs The current playback position in microseconds. If playback of this period has * not yet started, the value will be the starting position in this period minus the duration diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java index b119cad5b0..f32f5debfe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java @@ -38,8 +38,6 @@ public interface ChunkSource { /** * If the source is currently having difficulty providing chunks, then this method throws the * underlying error. Otherwise does nothing. - *

        - * This method should only be called after the source has been prepared. * * @throws IOException The underlying error. */ @@ -47,10 +45,12 @@ public interface ChunkSource { /** * Evaluates whether {@link MediaChunk}s should be removed from the back of the queue. - *

        - * Removing {@link MediaChunk}s from the back of the queue can be useful if they could be replaced - * with chunks of a significantly higher quality (e.g. because the available bandwidth has - * substantially increased). + * + *

        Removing {@link MediaChunk}s from the back of the queue can be useful if they could be + * replaced with chunks of a significantly higher quality (e.g. because the available bandwidth + * has substantially increased). + * + *

        Will only be called if no {@link MediaChunk} in the queue is currently loading. * * @param playbackPositionUs The current playback position. * @param queue The queue of buffered {@link MediaChunk}s. @@ -85,8 +85,6 @@ public interface ChunkSource { * Called when the {@link ChunkSampleStream} has finished loading a chunk obtained from this * source. * - *

        This method should only be called when the source is enabled. - * * @param chunk The chunk whose load has been completed. */ void onChunkLoadCompleted(Chunk chunk); @@ -95,8 +93,6 @@ public interface ChunkSource { * Called when the {@link ChunkSampleStream} encounters an error loading a chunk obtained from * this source. * - *

        This method should only be called when the source is enabled. - * * @param chunk The chunk whose load encountered the error. * @param cancelable Whether the load can be canceled. * @param e The error. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java index ad1a6ef1f2..1b92a37f54 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.upstream.BandwidthMeter; @@ -93,8 +94,8 @@ public interface TrackSelection { /** * Enables the track selection. Dynamic changes via {@link #updateSelectedTrack(long, long, long, - * List, MediaChunkIterator[])} or {@link #evaluateQueueSize(long, List)} will only happen after - * this call. + * List, MediaChunkIterator[])}, {@link #evaluateQueueSize(long, List)} or {@link + * #shouldCancelChunkLoad(long, Chunk, List)} will only happen after this call. * *

        This method may not be called when the track selection is already enabled. */ @@ -102,8 +103,8 @@ public interface TrackSelection { /** * Disables this track selection. No further dynamic changes via {@link #updateSelectedTrack(long, - * long, long, List, MediaChunkIterator[])} or {@link #evaluateQueueSize(long, List)} will happen - * after this call. + * long, long, List, MediaChunkIterator[])}, {@link #evaluateQueueSize(long, List)} or {@link + * #shouldCancelChunkLoad(long, Chunk, List)} will happen after this call. * *

        This method may only be called when the track selection is already enabled. */ @@ -202,7 +203,7 @@ public interface TrackSelection { /** * Updates the selected track for sources that load media in discrete {@link MediaChunk}s. * - *

        This method may only be called when the selection is enabled. + *

        This method will only be called when the selection is enabled. * * @param playbackPositionUs The current playback position in microseconds. If playback of the * period to which this track selection belongs has not yet started, the value will be the @@ -231,34 +232,77 @@ public interface TrackSelection { MediaChunkIterator[] mediaChunkIterators); /** - * May be called periodically by sources that load media in discrete {@link MediaChunk}s and - * support discarding of buffered chunks in order to re-buffer using a different selected track. * Returns the number of chunks that should be retained in the queue. - *

        - * To avoid excessive re-buffering, implementations should normally return the size of the queue. - * An example of a case where a smaller value may be returned is if network conditions have + * + *

        May be called by sources that load media in discrete {@link MediaChunk MediaChunks} and + * support discarding of buffered chunks. + * + *

        To avoid excessive re-buffering, implementations should normally return the size of the + * queue. An example of a case where a smaller value may be returned is if network conditions have * improved dramatically, allowing chunks to be discarded and re-buffered in a track of * significantly higher quality. Discarding chunks may allow faster switching to a higher quality - * track in this case. This method may only be called when the selection is enabled. + * track in this case. + * + *

        Note that even if the source supports discarding of buffered chunks, the actual number of + * discarded chunks is not guaranteed. The source will call {@link #updateSelectedTrack(long, + * long, long, List, MediaChunkIterator[])} with the updated queue of chunks before loading a new + * chunk to allow switching to another quality. + * + *

        This method will only be called when the selection is enabled and none of the {@link + * MediaChunk MediaChunks} in the queue are currently loading. * * @param playbackPositionUs The current playback position in microseconds. If playback of the * period to which this track selection belongs has not yet started, the value will be the * starting position in the period minus the duration of any media in previous periods still * to be played. - * @param queue The queue of buffered {@link MediaChunk}s. Must not be modified. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}. Must not be modified. * @return The number of chunks to retain in the queue. */ int evaluateQueueSize(long playbackPositionUs, List queue); + /** + * Returns whether an ongoing load of a chunk should be canceled. + * + *

        May be called by sources that load media in discrete {@link MediaChunk MediaChunks} and + * support canceling the ongoing chunk load. The ongoing chunk load is either the last {@link + * MediaChunk} in the queue or another type of {@link Chunk}, for example, if the source loads + * initialization or encryption data. + * + *

        To avoid excessive re-buffering, implementations should normally return {@code false}. An + * example where {@code true} might be returned is if a load of a high quality chunk gets stuck + * and canceling this load in favor of a lower quality alternative may avoid a rebuffer. + * + *

        The source will call {@link #evaluateQueueSize(long, List)} after the cancelation finishes + * to allow discarding of chunks, and {@link #updateSelectedTrack(long, long, long, List, + * MediaChunkIterator[])} before loading a new chunk to allow switching to another quality. + * + *

        This method will only be called when the selection is enabled. + * + * @param playbackPositionUs The current playback position in microseconds. If playback of the + * period to which this track selection belongs has not yet started, the value will be the + * starting position in the period minus the duration of any media in previous periods still + * to be played. + * @param loadingChunk The currently loading {@link Chunk} that will be canceled if this method + * returns {@code true}. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}, including the {@code + * loadingChunk} if it's a {@link MediaChunk}. Must not be modified. + * @return Whether the ongoing load of {@code loadingChunk} should be canceled. + */ + default boolean shouldCancelChunkLoad( + long playbackPositionUs, Chunk loadingChunk, List queue) { + return false; + } + /** * Attempts to blacklist the track at the specified index in the selection, making it ineligible * for selection by calls to {@link #updateSelectedTrack(long, long, long, List, - * MediaChunkIterator[])} for the specified period of time. Blacklisting will fail if all other - * tracks are currently blacklisted. If blacklisting the currently selected track, note that it - * will remain selected until the next call to {@link #updateSelectedTrack(long, long, long, List, - * MediaChunkIterator[])}. + * MediaChunkIterator[])} for the specified period of time. * - *

        This method may only be called when the selection is enabled. + *

        Blacklisting will fail if all other tracks are currently blacklisted. If blacklisting the + * currently selected track, note that it will remain selected until the next call to {@link + * #updateSelectedTrack(long, long, long, List, MediaChunkIterator[])}. + * + *

        This method will only be called when the selection is enabled. * * @param index The index of the track in the selection. * @param blacklistDurationMs The duration of time for which the track should be blacklisted, in diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index 21d1bc4d6b..8f88160ec3 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -458,6 +458,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * could be replaced with chunks of a significantly higher quality (e.g. because the available * bandwidth has substantially increased). * + *

        Will only be called if no {@link MediaChunk} in the queue is currently loading. + * * @param playbackPositionUs The current playback position, in microseconds. * @param queue The queue of buffered {@link MediaChunk MediaChunks}. * @return The preferred queue size. @@ -469,6 +471,22 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return trackSelection.evaluateQueueSize(playbackPositionUs, queue); } + /** + * Returns whether an ongoing load of a chunk should be canceled. + * + * @param playbackPositionUs The current playback position, in microseconds. + * @param loadingChunk The currently loading {@link Chunk}. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}. + * @return Whether the ongoing load of {@code loadingChunk} should be canceled. + */ + public boolean shouldCancelLoad( + long playbackPositionUs, Chunk loadingChunk, List queue) { + if (fatalError != null) { + return false; + } + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue); + } + // Private methods. /** diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 28aba78558..979b24f939 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -133,6 +133,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ArrayList hlsSampleStreams; private final Map overridingDrmInitData; + @Nullable private Chunk loadingChunk; private HlsSampleQueue[] sampleQueues; private int[] sampleQueueTrackIds; private Set sampleQueueMappingDoneByType; @@ -674,6 +675,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (isMediaChunk(loadable)) { initMediaChunkLoad((HlsMediaChunk) loadable); } + loadingChunk = loadable; long elapsedRealtimeMs = loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); @@ -700,14 +702,16 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return; } - int currentQueueSize = mediaChunks.size(); - int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); - if (currentQueueSize <= preferredQueueSize) { + if (loader.isLoading()) { + Assertions.checkNotNull(loadingChunk); + if (chunkSource.shouldCancelLoad(positionUs, loadingChunk, readOnlyMediaChunks)) { + loader.cancelLoading(); + } return; } - if (loader.isLoading()) { - loader.cancelLoading(); - } else { + + int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (preferredQueueSize < mediaChunks.size()) { discardUpstream(preferredQueueSize); } } @@ -716,6 +720,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + loadingChunk = null; chunkSource.onChunkLoadCompleted(loadable); LoadEventInfo loadEventInfo = new LoadEventInfo( @@ -746,6 +751,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void onLoadCanceled( Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { + loadingChunk = null; LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -841,6 +847,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; error, wasCanceled); if (wasCanceled) { + loadingChunk = null; loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); } @@ -885,7 +892,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (newQueueSize == C.LENGTH_UNSET) { return; } - + long endTimeUs = getLastMediaChunk().endTimeUs; HlsMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); if (mediaChunks.isEmpty()) { From 3ce57ae2e8ab2dc877afaa97faf50fb990779c1c Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 10 Jun 2020 16:32:25 +0100 Subject: [PATCH 0453/1052] Remove dropped MediaCodecAdadpters Delete the AsynchronousMediaCodecAdapter, the MultiLockAsyncMediaCodecAdapter and their tests. PiperOrigin-RevId: 315694296 --- .../AsynchronousMediaCodecAdapter.java | 159 -------- .../mediacodec/MediaCodecRenderer.java | 52 +-- .../MultiLockAsyncMediaCodecAdapter.java | 385 ------------------ .../AsynchronousMediaCodecAdapterTest.java | 303 -------------- .../MultiLockAsyncMediaCodecAdapterTest.java | 331 --------------- 5 files changed, 5 insertions(+), 1225 deletions(-) delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java delete mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java delete mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java deleted file mode 100644 index 040ef340ed..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; -import com.google.android.exoplayer2.decoder.CryptoInfo; -import com.google.android.exoplayer2.util.Assertions; - -/** - * A {@link MediaCodecAdapter} that operates the {@link MediaCodec} in asynchronous mode. - * - *

        The AsynchronousMediaCodecAdapter routes callbacks to the current thread's {@link Looper} - * obtained via {@link Looper#myLooper()} - */ -@RequiresApi(21) -/* package */ final class AsynchronousMediaCodecAdapter implements MediaCodecAdapter { - private final MediaCodecAsyncCallback mediaCodecAsyncCallback; - private final Handler handler; - private final MediaCodec codec; - @Nullable private IllegalStateException internalException; - private boolean flushing; - private Runnable codecStartRunnable; - - /** - * Create a new {@code AsynchronousMediaCodecAdapter}. - * - * @param codec The {@link MediaCodec} to wrap. - */ - public AsynchronousMediaCodecAdapter(MediaCodec codec) { - this(codec, Assertions.checkNotNull(Looper.myLooper())); - } - - @VisibleForTesting - /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, Looper looper) { - mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); - handler = new Handler(looper); - this.codec = codec; - this.codec.setCallback(mediaCodecAsyncCallback); - codecStartRunnable = codec::start; - } - - @Override - public void start() { - codecStartRunnable.run(); - } - - @Override - public void queueInputBuffer( - int index, int offset, int size, long presentationTimeUs, int flags) { - codec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); - } - - @Override - public void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - codec.queueSecureInputBuffer( - index, offset, info.getFrameworkCryptoInfo(), presentationTimeUs, flags); - } - - @Override - public int dequeueInputBufferIndex() { - if (flushing) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueInputBufferIndex(); - } - } - - @Override - public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - if (flushing) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); - } - } - - @Override - public MediaFormat getOutputFormat() { - return mediaCodecAsyncCallback.getOutputFormat(); - } - - @Override - public void flush() { - clearPendingFlushState(); - flushing = true; - codec.flush(); - handler.post(this::onCompleteFlush); - } - - @Override - public void shutdown() { - clearPendingFlushState(); - } - - @VisibleForTesting - /* package */ MediaCodec.Callback getMediaCodecCallback() { - return mediaCodecAsyncCallback; - } - - private void onCompleteFlush() { - flushing = false; - mediaCodecAsyncCallback.flush(); - try { - codecStartRunnable.run(); - } catch (IllegalStateException e) { - // Catch IllegalStateException directly so that we don't have to wrap it. - internalException = e; - } catch (Exception e) { - internalException = new IllegalStateException(e); - } - } - - @VisibleForTesting - /* package */ void setCodecStartRunnable(Runnable codecStartRunnable) { - this.codecStartRunnable = codecStartRunnable; - } - - private void maybeThrowException() throws IllegalStateException { - maybeThrowInternalException(); - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - } - - private void maybeThrowInternalException() { - if (internalException != null) { - IllegalStateException e = internalException; - internalException = null; - throw e; - } - } - - /** Clear state related to pending flush events. */ - private void clearPendingFlushState() { - handler.removeCallbacksAndMessages(null); - internalException = null; - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index b85fbe3a71..27f0621cfc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -73,11 +73,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * *

          *
        • {@link #OPERATION_MODE_SYNCHRONOUS} - *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD} *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} - *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK} *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING} - *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING} *
        */ @Documented @@ -85,42 +82,27 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) @IntDef({ OPERATION_MODE_SYNCHRONOUS, - OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD, OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD, - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK, OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING, - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING }) public @interface MediaCodecOperationMode {} + // TODO: Refactor these constants once internal evaluation completed. + // Do not assign values 1, 3 and 5 to a new operation mode until the evaluation is completed, + // otherwise existing clients may operate one of the dropped modes. + // [Internal ref: b/132684114] /** Operates the {@link MediaCodec} in synchronous mode. */ public static final int OPERATION_MODE_SYNCHRONOUS = 0; - /** - * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} - * callbacks to the playback thread. - */ - public static final int OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD = 1; /** * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} * callbacks to a dedicated thread. */ public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD = 2; - /** - * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} - * callbacks to a dedicated thread. Uses granular locking for input and output buffers. - */ - public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK = 3; /** * Same as {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD}, and offloads queueing to another * thread. */ public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING = 4; - /** - * Same as {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK}, and offloads queueing - * to another thread. - */ - public static final int - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING = 5; /** Thrown when a failure occurs instantiating a decoder. */ public static class DecoderInitializationException extends Exception { @@ -488,25 +470,13 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *
          *
        • {@link #OPERATION_MODE_SYNCHRONOUS}: The {@link MediaCodec} will operate in * synchronous mode. - *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD}: The {@link MediaCodec} will - * operate in asynchronous mode and {@link MediaCodec.Callback} callbacks will be routed - * to the playback thread. This mode requires API level ≥ 21; if the API level is - * ≤ 20, the operation mode will be set to {@link - * MediaCodecRenderer#OPERATION_MODE_SYNCHRONOUS}. *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD}: The {@link MediaCodec} will * operate in asynchronous mode and {@link MediaCodec.Callback} callbacks will be routed * to a dedicated thread. This mode requires API level ≥ 23; if the API level is ≤ * 22, the operation mode will be set to {@link #OPERATION_MODE_SYNCHRONOUS}. - *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK}: Same as {@link - * #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} and, in addition, input buffers will - * submitted to the {@link MediaCodec} in a separate thread. *
        • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING}: Same as * {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} and, in addition, input buffers * will be submitted to the {@link MediaCodec} in a separate thread. - *
        • {@link - * #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING}: Same - * as {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK} and, in addition, - * input buffers will be submitted to the {@link MediaCodec} in a separate thread. *
        * By default, the operation mode is set to {@link * MediaCodecRenderer#OPERATION_MODE_SYNCHRONOUS}. @@ -1103,27 +1073,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createCodec:" + codecName); codec = MediaCodec.createByCodecName(codecName); - if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD - && Util.SDK_INT >= 21) { - codecAdapter = new AsynchronousMediaCodecAdapter(codec); - } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD + if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD && Util.SDK_INT >= 23) { codecAdapter = new DedicatedThreadAsyncMediaCodecAdapter(codec, getTrackType()); - } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK - && Util.SDK_INT >= 23) { - codecAdapter = new MultiLockAsyncMediaCodecAdapter(codec, getTrackType()); } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING && Util.SDK_INT >= 23) { codecAdapter = new DedicatedThreadAsyncMediaCodecAdapter( codec, /* enableAsynchronousQueueing= */ true, getTrackType()); - } else if (mediaCodecOperationMode - == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING - && Util.SDK_INT >= 23) { - codecAdapter = - new MultiLockAsyncMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ true, getTrackType()); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java deleted file mode 100644 index d51f985ed7..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java +++ /dev/null @@ -1,385 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import androidx.annotation.GuardedBy; -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.decoder.CryptoInfo; -import com.google.android.exoplayer2.util.IntArrayQueue; -import com.google.android.exoplayer2.util.Util; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.ArrayDeque; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; - -/** - * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode - * and routes {@link MediaCodec.Callback} callbacks on a dedicated thread that is managed - * internally. - * - *

        The main difference of this class compared to the {@link - * DedicatedThreadAsyncMediaCodecAdapter} is that its internal implementation applies finer-grained - * locking. The {@link DedicatedThreadAsyncMediaCodecAdapter} uses a single lock to synchronize - * access, whereas this class uses a different lock to access the available input and available - * output buffer indexes returned from the {@link MediaCodec}. This class assumes that the {@link - * MediaCodecAdapter} methods will be accessed by the playback thread and the {@link - * MediaCodec.Callback} methods will be accessed by the internal thread. This class is - * NOT generally thread-safe in the sense that its public methods cannot be called - * by any thread. - */ -@RequiresApi(23) -/* package */ final class MultiLockAsyncMediaCodecAdapter extends MediaCodec.Callback - implements MediaCodecAdapter { - - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_CREATED, STATE_STARTED, STATE_SHUT_DOWN}) - private @interface State {} - - private static final int STATE_CREATED = 0; - private static final int STATE_STARTED = 1; - private static final int STATE_SHUT_DOWN = 2; - - private final MediaCodec codec; - private final Object inputBufferLock; - private final Object outputBufferLock; - private final Object objectStateLock; - - @GuardedBy("inputBufferLock") - private final IntArrayQueue availableInputBuffers; - - @GuardedBy("outputBufferLock") - private final IntArrayQueue availableOutputBuffers; - - @GuardedBy("outputBufferLock") - private final ArrayDeque bufferInfos; - - @GuardedBy("outputBufferLock") - private final ArrayDeque formats; - - @GuardedBy("objectStateLock") - private @MonotonicNonNull MediaFormat currentFormat; - - @GuardedBy("objectStateLock") - private long pendingFlush; - - @GuardedBy("objectStateLock") - @Nullable - private IllegalStateException codecException; - - private final HandlerThread handlerThread; - private @MonotonicNonNull Handler handler; - private Runnable codecStartRunnable; - private final MediaCodecInputBufferEnqueuer bufferEnqueuer; - - @GuardedBy("objectStateLock") - @State - private int state; - - /** - * Creates a new instance that wraps the specified {@link MediaCodec}. An instance created with - * this constructor will queue input buffers synchronously. - * - * @param codec The {@link MediaCodec} to wrap. - * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for - * labelling the internal thread accordingly. - */ - /* package */ MultiLockAsyncMediaCodecAdapter(MediaCodec codec, int trackType) { - this( - codec, - /* enableAsynchronousQueueing= */ false, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - - /** - * Creates a new instance that wraps the specified {@link MediaCodec}. - * - * @param codec The {@link MediaCodec} to wrap. - * @param enableAsynchronousQueueing Whether input buffers will be queued asynchronously. - * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for - * labelling the internal thread accordingly. - */ - /* package */ MultiLockAsyncMediaCodecAdapter( - MediaCodec codec, boolean enableAsynchronousQueueing, int trackType) { - this( - codec, - enableAsynchronousQueueing, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - - @VisibleForTesting - /* package */ MultiLockAsyncMediaCodecAdapter( - MediaCodec codec, - boolean enableAsynchronousQueueing, - int trackType, - HandlerThread handlerThread) { - this.codec = codec; - inputBufferLock = new Object(); - outputBufferLock = new Object(); - objectStateLock = new Object(); - availableInputBuffers = new IntArrayQueue(); - availableOutputBuffers = new IntArrayQueue(); - bufferInfos = new ArrayDeque<>(); - formats = new ArrayDeque<>(); - codecException = null; - this.handlerThread = handlerThread; - codecStartRunnable = codec::start; - if (enableAsynchronousQueueing) { - bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); - } else { - bufferEnqueuer = new SynchronousMediaCodecBufferEnqueuer(codec); - } - state = STATE_CREATED; - } - - @Override - public void start() { - synchronized (objectStateLock) { - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); - codec.setCallback(this, handler); - bufferEnqueuer.start(); - codecStartRunnable.run(); - state = STATE_STARTED; - } - } - - @Override - public int dequeueInputBufferIndex() { - synchronized (objectStateLock) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return dequeueAvailableInputBufferIndex(); - } - } - } - - @Override - public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - synchronized (objectStateLock) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return dequeueAvailableOutputBufferIndex(bufferInfo); - } - } - } - - @Override - public MediaFormat getOutputFormat() { - synchronized (objectStateLock) { - if (currentFormat == null) { - throw new IllegalStateException(); - } - - return currentFormat; - } - } - - @Override - public void queueInputBuffer( - int index, int offset, int size, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it is not interacting with - // MediaCodec.Callback and dequeueing buffers operations. - bufferEnqueuer.queueInputBuffer(index, offset, size, presentationTimeUs, flags); - } - - @Override - public void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it is not interacting with - // MediaCodec.Callback and dequeueing buffers operations. - bufferEnqueuer.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); - } - - @Override - public void flush() { - synchronized (objectStateLock) { - bufferEnqueuer.flush(); - codec.flush(); - pendingFlush++; - Util.castNonNull(handler).post(this::onFlushComplete); - } - } - - @Override - public void shutdown() { - synchronized (objectStateLock) { - if (state == STATE_STARTED) { - bufferEnqueuer.shutdown(); - handlerThread.quit(); - } - state = STATE_SHUT_DOWN; - } - } - - @VisibleForTesting - /* package */ void setCodecStartRunnable(Runnable codecStartRunnable) { - this.codecStartRunnable = codecStartRunnable; - } - - private int dequeueAvailableInputBufferIndex() { - synchronized (inputBufferLock) { - return availableInputBuffers.isEmpty() - ? MediaCodec.INFO_TRY_AGAIN_LATER - : availableInputBuffers.remove(); - } - } - - @GuardedBy("objectStateLock") - private int dequeueAvailableOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - int bufferIndex; - synchronized (outputBufferLock) { - if (availableOutputBuffers.isEmpty()) { - bufferIndex = MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - bufferIndex = availableOutputBuffers.remove(); - if (bufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - currentFormat = formats.remove(); - } else if (bufferIndex >= 0) { - MediaCodec.BufferInfo outBufferInfo = bufferInfos.remove(); - bufferInfo.set( - outBufferInfo.offset, - outBufferInfo.size, - outBufferInfo.presentationTimeUs, - outBufferInfo.flags); - } - } - } - return bufferIndex; - } - - @GuardedBy("objectStateLock") - private boolean isFlushing() { - return pendingFlush > 0; - } - - @GuardedBy("objectStateLock") - private void maybeThrowException() { - @Nullable IllegalStateException exception = codecException; - if (exception != null) { - codecException = null; - throw exception; - } - } - - // Called by the internal thread. - - @Override - public void onInputBufferAvailable(MediaCodec codec, int index) { - synchronized (inputBufferLock) { - availableInputBuffers.add(index); - } - } - - @Override - public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) { - synchronized (outputBufferLock) { - availableOutputBuffers.add(index); - bufferInfos.add(info); - } - } - - @Override - public void onError(MediaCodec codec, MediaCodec.CodecException e) { - onMediaCodecError(e); - } - - @Override - public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { - synchronized (outputBufferLock) { - availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - formats.add(format); - } - } - - @VisibleForTesting - /* package */ void onMediaCodecError(IllegalStateException e) { - synchronized (objectStateLock) { - codecException = e; - } - } - - private void onFlushComplete() { - synchronized (objectStateLock) { - if (state == STATE_SHUT_DOWN) { - return; - } - - --pendingFlush; - if (pendingFlush > 0) { - // Another flush() has been called. - return; - } else if (pendingFlush < 0) { - // This should never happen. - codecException = new IllegalStateException(); - return; - } - - clearAvailableInput(); - clearAvailableOutput(); - codecException = null; - try { - codecStartRunnable.run(); - } catch (IllegalStateException e) { - codecException = e; - } catch (Exception e) { - codecException = new IllegalStateException(e); - } - } - } - - private void clearAvailableInput() { - synchronized (inputBufferLock) { - availableInputBuffers.clear(); - } - } - - private void clearAvailableOutput() { - synchronized (outputBufferLock) { - availableOutputBuffers.clear(); - bufferInfos.clear(); - formats.clear(); - } - } - - private static String createThreadLabel(int trackType) { - StringBuilder labelBuilder = new StringBuilder("ExoPlayer:MediaCodecAsyncAdapter:"); - if (trackType == C.TRACK_TYPE_AUDIO) { - labelBuilder.append("Audio"); - } else if (trackType == C.TRACK_TYPE_VIDEO) { - labelBuilder.append("Video"); - } else { - labelBuilder.append("Unknown(").append(trackType).append(")"); - } - return labelBuilder.toString(); - } -} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java deleted file mode 100644 index c36bf74c9c..0000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import static com.google.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; -import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; - -/** Unit tests for {@link AsynchronousMediaCodecAdapter}. */ -@LooperMode(LEGACY) -@RunWith(AndroidJUnit4.class) -public class AsynchronousMediaCodecAdapterTest { - private AsynchronousMediaCodecAdapter adapter; - private MediaCodec codec; - private HandlerThread handlerThread; - private Looper looper; - private MediaCodec.BufferInfo bufferInfo; - - @Before - public void setUp() throws IOException { - handlerThread = new HandlerThread("TestHandler"); - handlerThread.start(); - looper = handlerThread.getLooper(); - codec = MediaCodec.createByCodecName("h264"); - adapter = new AsynchronousMediaCodecAdapter(codec, looper); - adapter.setCodecStartRunnable(() -> {}); - bufferInfo = new MediaCodec.BufferInfo(); - } - - @After - public void tearDown() { - adapter.shutdown(); - handlerThread.quit(); - } - - @Test - public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { - adapter.start(); - adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 0); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); - } - - @Test - public void dequeueInputBufferIndex_whileFlushing_returnsTryAgainLater() { - adapter.start(); - - adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 0); - // A callback that is pending. - new Handler(looper) - .post(() -> adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 1)); - adapter.flush(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_afterFlushCompletes_returnsNextInputBuffer() { - adapter.start(); - Handler handler = new Handler(looper); - handler.post( - () -> adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 0)); - adapter.flush(); // enqueues a flush event on the looper - handler.post( - () -> adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 1)); - - // Wait until all tasks have been handled. - shadowOf(looper).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(1); - } - - @Test - public void dequeueInputBufferIndex_afterFlushCompletesWithError_throwsException() { - AtomicInteger calls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (calls.incrementAndGet() == 2) { - throw new IllegalStateException(); - } - }); - adapter.start(); - adapter.flush(); - - // Wait until all tasks have been handled. - shadowOf(looper).idle(); - assertThrows( - IllegalStateException.class, - () -> { - adapter.dequeueInputBufferIndex(); - }); - } - - @Test - public void dequeueOutputBufferIndex_withoutOutputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { - adapter.start(); - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - outBufferInfo.presentationTimeUs = 10; - adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 0, outBufferInfo); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(0); - assertBufferInfosEqual(bufferInfo, outBufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_whileFlushing_returnsTryAgainLater() { - adapter.start(); - adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 0, bufferInfo); - adapter.flush(); - adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 1, bufferInfo); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_afterFlushCompletes_returnsNextOutputBuffer() { - adapter.start(); - Handler handler = new Handler(looper); - MediaCodec.BufferInfo info0 = new MediaCodec.BufferInfo(); - handler.post( - () -> adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 0, info0)); - adapter.flush(); // enqueues a flush event on the looper - MediaCodec.BufferInfo info1 = new MediaCodec.BufferInfo(); - info1.presentationTimeUs = 1; - handler.post( - () -> adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 1, info1)); - - // Wait until all tasks have been handled. - shadowOf(looper).idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(1); - assertBufferInfosEqual(info1, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_afterFlushCompletesWithError_throwsException() { - AtomicInteger calls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (calls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - // Wait until all tasks have been handled. - shadowOf(looper).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void dequeueOutputBufferIndex_withPendingOutputFormat_returnsPendingOutputFormat() { - MediaFormat pendingOutputFormat = new MediaFormat(); - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - MediaCodec.Callback mediaCodecCallback = adapter.getMediaCodecCallback(); - Handler handler = new Handler(looper); - adapter.start(); - - // Enqueue callbacks - handler.post(() -> mediaCodecCallback.onOutputFormatChanged(codec, new MediaFormat())); - handler.post( - () -> - mediaCodecCallback.onOutputBufferAvailable( - codec, /* index= */ 0, new MediaCodec.BufferInfo())); - handler.post(() -> mediaCodecCallback.onOutputFormatChanged(codec, pendingOutputFormat)); - handler.post( - () -> - mediaCodecCallback.onOutputBufferAvailable( - codec, /* index= */ 1, new MediaCodec.BufferInfo())); - adapter.flush(); - // After flush is complete, MediaCodec sends on output buffer. - handler.post( - () -> - mediaCodecCallback.onOutputBufferAvailable( - codec, /* index= */ 2, new MediaCodec.BufferInfo())); - shadowOf(looper).idle(); - - assertThat(adapter.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(pendingOutputFormat); - assertThat(adapter.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(2); - } - - @Test - public void dequeueOutputBufferIndex_withPendingAndNewOutputFormat_returnsNewOutputFormat() { - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - MediaCodec.Callback mediaCodecCallback = adapter.getMediaCodecCallback(); - Handler handler = new Handler(looper); - adapter.start(); - - // Enqueue callbacks - handler.post(() -> mediaCodecCallback.onOutputFormatChanged(codec, new MediaFormat())); - handler.post( - () -> - mediaCodecCallback.onOutputBufferAvailable( - codec, /* index= */ 0, new MediaCodec.BufferInfo())); - adapter.flush(); - // After flush is complete, MediaCodec sends an output format change, it should overwrite - // the pending format. - MediaFormat newMediaFormat = new MediaFormat(); - handler.post(() -> mediaCodecCallback.onOutputFormatChanged(codec, newMediaFormat)); - shadowOf(looper).idle(); - - assertThat(adapter.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(newMediaFormat); - } - - @Test - public void getOutputFormat_withMultipleFormats_returnsFormatsInCorrectOrder() { - adapter.start(); - MediaFormat[] formats = new MediaFormat[10]; - MediaCodec.Callback mediaCodecCallback = adapter.getMediaCodecCallback(); - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - mediaCodecCallback.onOutputFormatChanged(codec, formats[i]); - } - - for (MediaFormat format : formats) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - // Call it again to ensure same format is returned - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - // Obtain next output buffer - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - // Format should remain as is - assertThat(adapter.getOutputFormat()).isEqualTo(formats[formats.length - 1]); - } - - @Test - public void getOutputFormat_afterFlush_returnsPreviousFormat() { - adapter.start(); - MediaFormat format = new MediaFormat(); - adapter.getMediaCodecCallback().onOutputFormatChanged(codec, format); - adapter.dequeueOutputBufferIndex(bufferInfo); - adapter.flush(); - - // Wait until all tasks have been handled. - shadowOf(looper).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - - @Test - public void shutdown_withPendingFlush_cancelsFlush() { - AtomicInteger onCodecStartCalled = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> onCodecStartCalled.incrementAndGet()); - adapter.start(); - adapter.flush(); - adapter.shutdown(); - - // Wait until all tasks have been handled. - shadowOf(looper).idle(); - assertThat(onCodecStartCalled.get()).isEqualTo(1); - } -} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java deleted file mode 100644 index cfe9cf2900..0000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java +++ /dev/null @@ -1,331 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import static com.google.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; -import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; -import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Shadows; -import org.robolectric.annotation.LooperMode; -import org.robolectric.shadows.ShadowLooper; - -/** Unit tests for {@link MultiLockAsyncMediaCodecAdapter}. */ -@LooperMode(LEGACY) -@RunWith(AndroidJUnit4.class) -public class MultiLockAsyncMediaCodecAdapterTest { - private MultiLockAsyncMediaCodecAdapter adapter; - private MediaCodec codec; - private MediaCodec.BufferInfo bufferInfo; - private TestHandlerThread handlerThread; - - @Before - public void setUp() throws IOException { - codec = MediaCodec.createByCodecName("h264"); - handlerThread = new TestHandlerThread("TestHandlerThread"); - adapter = - new MultiLockAsyncMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ false, C.TRACK_TYPE_VIDEO, handlerThread); - adapter.setCodecStartRunnable(() -> {}); - bufferInfo = new MediaCodec.BufferInfo(); - } - - @After - public void tearDown() { - adapter.shutdown(); - - assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); - } - - @Test - public void startAndShutdown_works() { - adapter.start(); - adapter.shutdown(); - } - - @Test - public void dequeueInputBufferIndex_withAfterFlushFailed_throwsException() - throws InterruptedException { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new IllegalStateException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); - } - - @Test - public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { - adapter.start(); - adapter.onInputBufferAvailable(codec, 0); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); - } - - @Test - public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.start(); - adapter.onInputBufferAvailable(codec, 0); - adapter.flush(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() - throws InterruptedException { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - handler.post(() -> adapter.onInputBufferAvailable(codec, bufferIndex)); - } - adapter.flush(); // Enqueues a flush event after the onInputBufferAvailable callbacks - // Enqueue another onInputBufferAvailable after the flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 10)); - - // Wait until all tasks have been handled - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(10); - } - - @Test - public void dequeueInputBufferIndex_withMediaCodecError_throwsException() { - adapter.start(); - adapter.onMediaCodecError(new IllegalStateException("error from codec")); - - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); - } - - - @Test - public void dequeueOutputBufferIndex_withInternalException_throwsException() - throws InterruptedException { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void dequeueOutputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { - adapter.start(); - MediaCodec.BufferInfo enqueuedBufferInfo = new MediaCodec.BufferInfo(); - adapter.onOutputBufferAvailable(codec, 0, enqueuedBufferInfo); - - assertThat(adapter.dequeueOutputBufferIndex((bufferInfo))).isEqualTo(0); - assertBufferInfosEqual(enqueuedBufferInfo, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.start(); - adapter.dequeueOutputBufferIndex(bufferInfo); - adapter.flush(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withFlushCompletedAndOutputBuffer_returnsOutputBuffer() - throws InterruptedException { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - outBufferInfo.presentationTimeUs = i; - handler.post(() -> adapter.onOutputBufferAvailable(codec, bufferIndex, outBufferInfo)); - } - adapter.flush(); // Enqueues a flush event after the onOutputBufferAvailable callbacks - // Enqueue another onOutputBufferAvailable after the flush event - MediaCodec.BufferInfo lastBufferInfo = new MediaCodec.BufferInfo(); - lastBufferInfo.presentationTimeUs = 10; - handler.post(() -> adapter.onOutputBufferAvailable(codec, 10, lastBufferInfo)); - - // Wait until all tasks have been handled - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(10); - assertBufferInfosEqual(lastBufferInfo, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() { - adapter.start(); - adapter.onMediaCodecError(new IllegalStateException("error from codec")); - - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void getOutputFormat_withoutFormatReceived_throwsException() { - adapter.start(); - - assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); - } - - @Test - public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { - adapter.start(); - MediaFormat[] formats = new MediaFormat[10]; - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - adapter.onOutputFormatChanged(codec, formats[i]); - } - - for (int i = 0; i < 10; i++) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - // A subsequent call to getOutputFormat() should return the previously fetched format - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - } - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void getOutputFormat_afterFlush_returnsPreviousFormat() { - MediaFormat format = new MediaFormat(); - adapter.start(); - adapter.onOutputFormatChanged(codec, format); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - - adapter.flush(); - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - - @Test - public void flush_multipleTimes_onlyLastFlushExecutes() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> codecStartCalls.incrementAndGet()); - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - handler.post(() -> adapter.onInputBufferAvailable(codec, 0)); - adapter.flush(); // Enqueues a flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 2)); - AtomicInteger milestoneCount = new AtomicInteger(0); - handler.post(() -> milestoneCount.incrementAndGet()); - adapter.flush(); // Enqueues a second flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 3)); - - // Progress the looper until the milestoneCount is increased: - // adapter.start() called codec.start() but first flush event should have been a no-op - ShadowLooper shadowLooper = shadowOf(looper); - while (milestoneCount.get() < 1) { - shadowLooper.runOneTask(); - } - assertThat(codecStartCalls.get()).isEqualTo(1); - - shadowLooper.idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(3); - assertThat(codecStartCalls.get()).isEqualTo(2); - } - - @Test - public void flush_andImmediatelyShutdown_flushIsNoOp() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> codecStartCalls.incrementAndGet()); - adapter.start(); - // Grab reference to Looper before shutting down the adapter otherwise handlerThread.getLooper() - // might return null. - Looper looper = handlerThread.getLooper(); - adapter.flush(); - adapter.shutdown(); - - Shadows.shadowOf(looper).idle(); - // Only adapter.start() called codec#start() - assertThat(codecStartCalls.get()).isEqualTo(1); - } - - private static class TestHandlerThread extends HandlerThread { - - private static final AtomicLong INSTANCES_STARTED = new AtomicLong(0); - - public TestHandlerThread(String name) { - super(name); - } - - @Override - public synchronized void start() { - super.start(); - INSTANCES_STARTED.incrementAndGet(); - } - - @Override - public boolean quit() { - boolean quit = super.quit(); - INSTANCES_STARTED.decrementAndGet(); - return quit; - } - } -} From e7da26368a4554c8a1b6b65cc5a68b595a1738eb Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 10 Jun 2020 18:42:53 +0100 Subject: [PATCH 0454/1052] Add MediaParser-based implementation of ChunkExtractor PiperOrigin-RevId: 315720712 --- .../com/google/android/exoplayer2/util/MimeTypes.java | 10 ++++++++++ .../exoplayer2/source/dash/DefaultDashChunkSource.java | 8 ++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index 47f8cc5fbc..f2b160f368 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -529,6 +529,16 @@ public final class MimeTypes { } } + /** Returns whether the given {@code mimeType} is a WebM MIME type. */ + public static boolean isWebm(@Nullable String mimeType) { + if (mimeType == null) { + return false; + } + return mimeType.startsWith(MimeTypes.VIDEO_WEBM) + || mimeType.startsWith(MimeTypes.AUDIO_WEBM) + || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); + } + /** * Returns the top-level type of {@code mimeType}, or null if {@code mimeType} is null or does not * contain a forward slash character ({@code '/'}). diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index e34afaeb48..5a9cf67d4f 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -771,11 +771,6 @@ public class DefaultDashChunkSource implements DashChunkSource { return getFirstSegmentNum() + availableSegmentCount - 1; } - private static boolean mimeTypeIsWebm(String mimeType) { - return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM) - || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); - } - @Nullable private static ChunkExtractor createChunkExtractor( int trackType, @@ -784,6 +779,7 @@ public class DefaultDashChunkSource implements DashChunkSource { List closedCaptionFormats, @Nullable TrackOutput playerEmsgTrackOutput) { String containerMimeType = representation.format.containerMimeType; + Extractor extractor; if (MimeTypes.isText(containerMimeType)) { if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { @@ -793,7 +789,7 @@ public class DefaultDashChunkSource implements DashChunkSource { // All other text types are raw formats that do not need an extractor. return null; } - } else if (mimeTypeIsWebm(containerMimeType)) { + } else if (MimeTypes.isWebm(containerMimeType)) { extractor = new MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES); } else { int flags = 0; From 88b36abce616b6b62111800620f34536151a50b6 Mon Sep 17 00:00:00 2001 From: kimvde Date: Thu, 11 Jun 2020 09:48:59 +0100 Subject: [PATCH 0455/1052] Sniff all inferred extractors in DefaultHlsExtractorFactory PiperOrigin-RevId: 315857270 --- .../extractor/DefaultExtractorsFactory.java | 14 +- .../hls/DefaultHlsExtractorFactory.java | 136 +++++------- .../hls/DefaultHlsExtractorFactoryTest.java | 193 ++++++++++++++++++ 3 files changed, 253 insertions(+), 90 deletions(-) create mode 100644 library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 0cf017b800..5a49c93408 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -279,26 +279,26 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { @FileTypes.Type int responseHeadersInferredFileType = inferFileTypeFromResponseHeaders(responseHeaders); if (responseHeadersInferredFileType != FileTypes.UNKNOWN) { - addExtractorsForFormat(responseHeadersInferredFileType, extractors); + addExtractorsForFileType(responseHeadersInferredFileType, extractors); } @FileTypes.Type int uriInferredFileType = inferFileTypeFromUri(uri); if (uriInferredFileType != FileTypes.UNKNOWN && uriInferredFileType != responseHeadersInferredFileType) { - addExtractorsForFormat(uriInferredFileType, extractors); + addExtractorsForFileType(uriInferredFileType, extractors); } - for (int format : DEFAULT_EXTRACTOR_ORDER) { - if (format != responseHeadersInferredFileType && format != uriInferredFileType) { - addExtractorsForFormat(format, extractors); + for (int fileType : DEFAULT_EXTRACTOR_ORDER) { + if (fileType != responseHeadersInferredFileType && fileType != uriInferredFileType) { + addExtractorsForFileType(fileType, extractors); } } return extractors.toArray(new Extractor[extractors.size()]); } - private void addExtractorsForFormat(@FileTypes.Type int fileFormat, List extractors) { - switch (fileFormat) { + private void addExtractorsForFileType(@FileTypes.Type int fileType, List extractors) { + switch (fileType) { case FileTypes.AC3: extractors.add(new Ac3Extractor()); break; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 52d9e359cd..3176551a45 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.source.hls; -import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import android.net.Uri; import android.text.TextUtils; @@ -31,12 +31,12 @@ import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.FileTypes; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.EOFException; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -46,6 +46,19 @@ import java.util.Map; */ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + private static final int[] DEFAULT_EXTRACTOR_ORDER = + new int[] { + FileTypes.MP4, + FileTypes.WEBVTT, + FileTypes.TS, + FileTypes.ADTS, + FileTypes.AC3, + FileTypes.AC4, + FileTypes.MP3, + }; + @DefaultTsPayloadReaderFactory.Flags private final int payloadReaderFactoryFlags; private final boolean exposeCea608WhenMissingDeclarations; @@ -90,6 +103,7 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { if (isReusable(previousExtractor)) { return buildResult(previousExtractor); } else { + @Nullable Result result = buildResultForSameExtractorType(previousExtractor, format, timestampAdjuster); if (result == null) { @@ -99,100 +113,56 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } } - // Try selecting the extractor by the file extension. - @Nullable - Extractor inferredExtractor = - createInferredExtractor( - uri, format, muxedCaptionFormats, timestampAdjuster, responseHeaders); - extractorInput.resetPeekPosition(); - if (inferredExtractor != null && sniffQuietly(inferredExtractor, extractorInput)) { - return buildResult(inferredExtractor); - } + @FileTypes.Type + int formatInferredFileType = FileTypes.inferFileTypeFromMimeType(format.sampleMimeType); + @FileTypes.Type + int responseHeadersInferredFileType = + FileTypes.inferFileTypeFromResponseHeaders(responseHeaders); + @FileTypes.Type int uriInferredFileType = FileTypes.inferFileTypeFromUri(uri); - // We need to manually sniff each known type, without retrying the one selected by file - // extension. Extractors order is optimized according to - // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + // Defines the order in which to try the extractors. + List fileTypeOrder = + new ArrayList<>(/* initialCapacity= */ DEFAULT_EXTRACTOR_ORDER.length); + addFileTypeIfNotPresent(formatInferredFileType, fileTypeOrder); + addFileTypeIfNotPresent(responseHeadersInferredFileType, fileTypeOrder); + addFileTypeIfNotPresent(uriInferredFileType, fileTypeOrder); + for (int fileType : DEFAULT_EXTRACTOR_ORDER) { + addFileTypeIfNotPresent(fileType, fileTypeOrder); + } // Extractor to be used if the type is not recognized. - @Nullable Extractor fallBackExtractor = inferredExtractor; - - if (!(inferredExtractor instanceof FragmentedMp4Extractor)) { - FragmentedMp4Extractor fragmentedMp4Extractor = - createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); - if (sniffQuietly(fragmentedMp4Extractor, extractorInput)) { - return buildResult(fragmentedMp4Extractor); + @Nullable Extractor fallBackExtractor = null; + extractorInput.resetPeekPosition(); + for (int i = 0; i < fileTypeOrder.size(); i++) { + int fileType = fileTypeOrder.get(i); + Extractor extractor = + checkNotNull( + createExtractorByFileType(fileType, format, muxedCaptionFormats, timestampAdjuster)); + if (sniffQuietly(extractor, extractorInput)) { + return buildResult(extractor); + } + if (fileType == FileTypes.TS) { + fallBackExtractor = extractor; } } - if (!(inferredExtractor instanceof WebvttExtractor)) { - WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster); - if (sniffQuietly(webvttExtractor, extractorInput)) { - return buildResult(webvttExtractor); - } - } + return buildResult(checkNotNull(fallBackExtractor)); + } - if (!(inferredExtractor instanceof TsExtractor)) { - TsExtractor tsExtractor = - createTsExtractor( - payloadReaderFactoryFlags, - exposeCea608WhenMissingDeclarations, - format, - muxedCaptionFormats, - timestampAdjuster); - if (sniffQuietly(tsExtractor, extractorInput)) { - return buildResult(tsExtractor); - } - if (fallBackExtractor == null) { - fallBackExtractor = tsExtractor; - } + private static void addFileTypeIfNotPresent( + @FileTypes.Type int fileType, List fileTypes) { + if (fileType == FileTypes.UNKNOWN || fileTypes.contains(fileType)) { + return; } - - if (!(inferredExtractor instanceof AdtsExtractor)) { - AdtsExtractor adtsExtractor = new AdtsExtractor(); - if (sniffQuietly(adtsExtractor, extractorInput)) { - return buildResult(adtsExtractor); - } - } - - if (!(inferredExtractor instanceof Ac3Extractor)) { - Ac3Extractor ac3Extractor = new Ac3Extractor(); - if (sniffQuietly(ac3Extractor, extractorInput)) { - return buildResult(ac3Extractor); - } - } - - if (!(inferredExtractor instanceof Ac4Extractor)) { - Ac4Extractor ac4Extractor = new Ac4Extractor(); - if (sniffQuietly(ac4Extractor, extractorInput)) { - return buildResult(ac4Extractor); - } - } - - if (!(inferredExtractor instanceof Mp3Extractor)) { - Mp3Extractor mp3Extractor = - new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); - if (sniffQuietly(mp3Extractor, extractorInput)) { - return buildResult(mp3Extractor); - } - } - - return buildResult(Assertions.checkNotNull(fallBackExtractor)); + fileTypes.add(fileType); } @Nullable - private Extractor createInferredExtractor( - Uri uri, + private Extractor createExtractorByFileType( + @FileTypes.Type int fileType, Format format, @Nullable List muxedCaptionFormats, - TimestampAdjuster timestampAdjuster, - Map> responseHeaders) { - @FileTypes.Type int fileType = FileTypes.inferFileTypeFromMimeType(format.sampleMimeType); - if (fileType == FileTypes.UNKNOWN) { - fileType = FileTypes.inferFileTypeFromResponseHeaders(responseHeaders); - } - if (fileType == FileTypes.UNKNOWN) { - fileType = inferFileTypeFromUri(uri); - } + TimestampAdjuster timestampAdjuster) { switch (fileType) { case FileTypes.WEBVTT: return new WebvttExtractor(format.language, timestampAdjuster); diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java new file mode 100644 index 0000000000..d5f33424ab --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java @@ -0,0 +1,193 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.TimestampAdjuster; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link DefaultExtractorsFactory}. */ +@RunWith(AndroidJUnit4.class) +public class DefaultHlsExtractorFactoryTest { + + private Extractor fMp4Extractor; + private Uri tsUri; + private Format webVttFormat; + private TimestampAdjuster timestampAdjuster; + private Map> ac3ResponseHeaders; + + @Before + public void setUp() { + fMp4Extractor = new FragmentedMp4Extractor(); + tsUri = Uri.parse("http://path/filename.ts"); + webVttFormat = new Format.Builder().setSampleMimeType(MimeTypes.TEXT_VTT).build(); + timestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + ac3ResponseHeaders = new HashMap<>(); + ac3ResponseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.AUDIO_AC3)); + } + + @Test + public void createExtractor_withPreviousExtractor_returnsSameExtractorType() throws Exception { + ExtractorInput extractorInput = new FakeExtractorInput.Builder().build(); + + HlsExtractorFactory.Result result = + new DefaultHlsExtractorFactory() + .createExtractor( + /* previousExtractor= */ fMp4Extractor, + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + extractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(FragmentedMp4Extractor.class); + } + + @Test + public void createExtractor_withFileTypeInFormat_returnsExtractorMatchingFormat() + throws Exception { + ExtractorInput webVttExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "webvtt/typical")) + .build(); + + HlsExtractorFactory.Result result = + new DefaultHlsExtractorFactory() + .createExtractor( + /* previousExtractor= */ null, + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + webVttExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(WebvttExtractor.class); + } + + @Test + public void + createExtractor_withFileTypeInResponseHeaders_returnsExtractorMatchingResponseHeaders() + throws Exception { + ExtractorInput ac3ExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), "ts/sample.ac3")) + .build(); + + HlsExtractorFactory.Result result = + new DefaultHlsExtractorFactory() + .createExtractor( + /* previousExtractor= */ null, + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + ac3ExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(Ac3Extractor.class); + } + + @Test + public void createExtractor_withFileTypeInUri_returnsExtractorMatchingUri() throws Exception { + ExtractorInput tsExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "ts/sample_ac3.ts")) + .build(); + + HlsExtractorFactory.Result result = + new DefaultHlsExtractorFactory() + .createExtractor( + /* previousExtractor= */ null, + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + tsExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(TsExtractor.class); + } + + @Test + public void createExtractor_withFileTypeNotInMediaInfo_returnsExpectedExtractor() + throws Exception { + ExtractorInput mp3ExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "mp3/bear-id3.mp3")) + .build(); + + HlsExtractorFactory.Result result = + new DefaultHlsExtractorFactory() + .createExtractor( + /* previousExtractor= */ null, + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + mp3ExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(Mp3Extractor.class); + } + + @Test + public void createExtractor_withNoMatchingExtractor_fallsBackOnTsExtractor() throws Exception { + ExtractorInput emptyExtractorInput = new FakeExtractorInput.Builder().build(); + + HlsExtractorFactory.Result result = + new DefaultHlsExtractorFactory() + .createExtractor( + /* previousExtractor= */ null, + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + emptyExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(TsExtractor.class); + } +} From 032bb0498dcabfed897faa5ac5c10e2d52ff132f Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 11 Jun 2020 10:09:29 +0100 Subject: [PATCH 0456/1052] Move FakeRenderer's DrmSession releasing from onReset() to onDisable() This seems to match DecoderVideoRenderer more closely: https://github.com/google/ExoPlayer/blob/b1e56304a1fda8075fc637074927c0886f49fdf1/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java#L300 Although MediaCodecRenderer does it in onReset() and then calls that from onDisable(): https://github.com/google/ExoPlayer/blob/b1e56304a1fda8075fc637074927c0886f49fdf1/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java#L754 PiperOrigin-RevId: 315859212 --- .../com/google/android/exoplayer2/testutil/FakeRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java index e4f96e0147..d8a0724544 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java @@ -154,7 +154,7 @@ public class FakeRenderer extends BaseRenderer { } @Override - protected void onReset() { + protected void onDisabled() { if (currentDrmSession != null) { currentDrmSession.release(/* eventDispatcher= */ null); currentDrmSession = null; From e1beb1d1946bb8ca94f62578aee8cbadd97b6e2b Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 11 Jun 2020 10:21:03 +0100 Subject: [PATCH 0457/1052] Expose experimental offload scheduling Add a new scheduling mode that stops ExoPlayer main loop when the audio offload buffer is full and resume it when it has been partially played. This mode needs to be enabled and dissabled manually by the app for now. #exo-offload PiperOrigin-RevId: 315860373 --- RELEASENOTES.md | 1 + .../exoplayer2/DefaultRenderersFactory.java | 17 ++++-- .../google/android/exoplayer2/ExoPlayer.java | 37 ++++++++++++ .../android/exoplayer2/ExoPlayerImpl.java | 5 ++ .../exoplayer2/ExoPlayerImplInternal.java | 47 ++++++++++++++- .../google/android/exoplayer2/Renderer.java | 32 ++++++++++ .../android/exoplayer2/SimpleExoPlayer.java | 5 ++ .../android/exoplayer2/audio/AudioSink.java | 11 ++++ .../audio/AudioTrackPositionTracker.java | 5 ++ .../exoplayer2/audio/DefaultAudioSink.java | 58 ++++++++++++++++++- .../audio/MediaCodecAudioRenderer.java | 19 ++++++ .../exoplayer2/testutil/StubExoPlayer.java | 5 ++ 12 files changed, 236 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 82bdbec106..b60ffc169c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -158,6 +158,7 @@ * No longer use a `MediaCodec` in audio passthrough mode. * Check `DefaultAudioSink` supports passthrough, in addition to checking the `AudioCapabilities` + * Add an experimental scheduling mode to save power in offload. ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Adjust input timestamps in `MediaCodecRenderer` to account for the Codec2 MP3 decoder having lower timestamps on the output side. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index bd56974b32..3913922c3c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -219,12 +219,20 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Sets whether audio should be played using the offload path. Audio offload disables audio - * processors (for example speed adjustment). + * Sets whether audio should be played using the offload path. + * + *

        Audio offload disables ExoPlayer audio processing, but significantly reduces the energy + * consumption of the playback when {@link + * ExoPlayer#experimental_enableOffloadScheduling(boolean)} is enabled. + * + *

        Most Android devices can only support one offload {@link android.media.AudioTrack} at a time + * and can invalidate it at any time. Thus an app can never be guaranteed that it will be able to + * play in offload. * *

        The default value is {@code false}. * - * @param enableOffload If audio offload should be used. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. * @return This factory, for convenience. */ public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { @@ -423,7 +431,8 @@ public class DefaultRenderersFactory implements RenderersFactory { * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param enableOffload If the renderer should use audio offload for all supported formats. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b4cd9a399d..b3b369b68e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -20,6 +20,8 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.audio.AudioCapabilities; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; @@ -597,4 +599,39 @@ public interface ExoPlayer extends Player { * @see #setPauseAtEndOfMediaItems(boolean) */ boolean getPauseAtEndOfMediaItems(); + + /** + * Enables audio offload scheduling, which runs ExoPlayer's main loop as rarely as possible when + * playing an audio stream using audio offload. + * + *

        Only use this scheduling mode if the player is not displaying anything to the user. For + * example when the application is in the background, or the screen is off. The player state + * (including position) is rarely updated (between 10s and 1min). + * + *

        While offload scheduling is enabled, player events may be delivered severely delayed and + * apps should not interact with the player. When returning to the foreground, disable offload + * scheduling before interacting with the player + * + *

        This mode should save significant power when the phone is playing offload audio with the + * screen off. + * + *

        This mode only has an effect when playing an audio track in offload mode, which requires all + * the following: + * + *

          + *
        • audio offload rendering is enabled in {@link + * DefaultRenderersFactory#setEnableAudioOffload} or the equivalent option passed to {@link + * com.google.android.exoplayer2.audio.DefaultAudioSink#DefaultAudioSink(AudioCapabilities, + * DefaultAudioSink.AudioProcessorChain, boolean, boolean)}. + *
        • an audio track is playing in a format which the device supports offloading (for example + * MP3 or AAC). + *
        • The {@link com.google.android.exoplayer2.audio.AudioSink} is playing with an offload + * {@link android.media.AudioTrack}. + *
        + * + *

        This method is experimental, and will be renamed or removed in a future release. + * + * @param enableOffloadScheduling Whether to enable offload scheduling. + */ + void experimental_enableOffloadScheduling(boolean enableOffloadScheduling); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 26357a18dc..51c8a9ea60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -202,6 +202,11 @@ import java.util.concurrent.TimeoutException; internalPlayer.experimental_throwWhenStuckBuffering(); } + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + internalPlayer.experimental_enableOffloadScheduling(enableOffloadScheduling); + } + @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 53c8a5d080..c5e6b06c19 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -94,6 +94,15 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; + /** + * Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant + * power saving. + * + *

        This value is probably too high, power measurements are needed adjust it, but as renderer + * sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s, + * this does not matter for now. + */ + private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -127,6 +136,8 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; + private boolean requestForRendererSleep; + private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; @@ -199,6 +210,13 @@ import java.util.concurrent.atomic.AtomicBoolean; throwWhenStuckBuffering = true; } + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + offloadSchedulingEnabled = enableOffloadScheduling; + if (!enableOffloadScheduling) { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + } + public void prepare() { handler.obtainMessage(MSG_PREPARE).sendToTarget(); } @@ -885,12 +903,13 @@ import java.util.concurrent.atomic.AtomicBoolean; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } + requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -900,6 +919,14 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } + private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { + if (offloadSchedulingEnabled && requestForRendererSleep) { + return; + } + + scheduleNextWork(operationStartTimeMs, intervalMs); + } + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -2068,6 +2095,24 @@ import java.util.concurrent.atomic.AtomicBoolean; joining, mayRenderStartOfStream, periodHolder.getRendererOffset()); + + renderer.handleMessage( + Renderer.MSG_SET_WAKEUP_LISTENER, + new Renderer.WakeupListener() { + @Override + public void onSleep(long wakeupDeadlineMs) { + // Do not sleep if the expected sleep time is not long enough to save significant power. + if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) { + requestForRendererSleep = true; + } + } + + @Override + public void onWakeup() { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + }); + mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index fa73f9257d..8620c2d752 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -46,6 +46,30 @@ import java.lang.annotation.RetentionPolicy; */ public interface Renderer extends PlayerMessage.Target { + /** + * Some renderers can signal when {@link #render(long, long)} should be called. + * + *

        That allows the player to sleep until the next wakeup, instead of calling {@link + * #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save + * power. + */ + interface WakeupListener { + + /** + * The renderer no longer needs to render until the next wakeup. + * + * @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be + * called. + */ + void onSleep(long wakeupDeadlineMs); + + /** + * The renderer needs to render some frames. The client should call {@link #render(long, long)} + * at its earliest convenience. + */ + void onWakeup(); + } + /** * The type of a message that can be passed to a video renderer via {@link * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or @@ -137,6 +161,14 @@ public interface Renderer extends PlayerMessage.Target { * representing the audio session ID that will be attached to the underlying audio track. */ int MSG_SET_AUDIO_SESSION_ID = 102; + /** + * A type of a message that can be passed to a {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another + * component. + * + *

        The message payload must be a {@link WakeupListener} instance. + */ + int MSG_SET_WAKEUP_LISTENER = 103; /** * Applications or extensions may define custom {@code MSG_*} constants that can be passed to * renderers. These custom constants must be greater than or equal to this value. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index d1f0cfc798..4c36f9fc99 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -633,6 +633,11 @@ public class SimpleExoPlayer extends BasePlayer C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); } + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + player.experimental_enableOffloadScheduling(enableOffloadScheduling); + } + @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index c4fa25d6bf..8bebd97a67 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -90,6 +90,17 @@ public interface AudioSink { * @param skipSilenceEnabled Whether skipping silences is enabled. */ void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled); + + /** Called when the offload buffer has been partially emptied. */ + default void onOffloadBufferEmptying() {} + + /** + * Called when the offload buffer has been filled completely. + * + * @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link + * #onOffloadBufferEmptying()} will be called. + */ + default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index d15fe44fc0..ae2eb92044 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -335,6 +335,11 @@ import java.lang.reflect.Method; return bufferSize - bytesPending; } + /** Returns the duration of audio that is buffered but unplayed. */ + public long getPendingBufferDurationMs(long writtenFrames) { + return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition())); + } + /** Returns whether the track is in an invalid state and must be recreated. */ public boolean isStalled(long writtenFrames) { return forceResetWorkaroundTimeMs != C.TIME_UNSET diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index bc3c321cac..880aefdbbd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -20,6 +20,7 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; import android.os.ConditionVariable; +import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -35,6 +36,7 @@ import java.nio.ByteOrder; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -274,6 +276,7 @@ public final class DefaultAudioSink implements AudioSink { private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; private final boolean enableOffload; + @Nullable private final StreamEventCallback offloadStreamEventCallback; @Nullable private Listener listener; /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ @@ -366,7 +369,10 @@ public final class DefaultAudioSink implements AudioSink { * be available when float output is in use. * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played * with offload and encoded audio passthrough, it will be played in offload. Audio offload is - * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). + * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android + * devices can only support one offload {@link android.media.AudioTrack} at a time and can + * invalidate it at any time. Thus an app can never be guaranteed that it will be able to play + * in offload. */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, @@ -404,6 +410,7 @@ public final class DefaultAudioSink implements AudioSink { activeAudioProcessors = new AudioProcessor[0]; outputBuffers = new ByteBuffer[0]; mediaPositionParametersCheckpoints = new ArrayDeque<>(); + offloadStreamEventCallback = Util.SDK_INT >= 29 ? new StreamEventCallback() : null; } // AudioSink implementation. @@ -563,6 +570,10 @@ public final class DefaultAudioSink implements AudioSink { audioTrack = Assertions.checkNotNull(configuration) .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + if (isOffloadedPlayback(audioTrack)) { + // Receive stream event callbacks on the current (playback) thread. + Assertions.checkNotNull(offloadStreamEventCallback).register(audioTrack); + } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { @@ -822,6 +833,15 @@ public final class DefaultAudioSink implements AudioSink { throw new WriteException(bytesWritten); } + if (isOffloadedPlayback(audioTrack) + && playing + && listener != null + && bytesWritten < bytesRemaining) { + long pendingDurationMs = + audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); + listener.onOffloadBufferFull(pendingDurationMs); + } + if (configuration.isInputPcm) { writtenPcmBytes += bytesWritten; } @@ -1040,6 +1060,9 @@ public final class DefaultAudioSink implements AudioSink { if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } + if (isOffloadedPlayback(audioTrack)) { + offloadStreamEventCallback.unregister(audioTrack); + } // AudioTrack.release can take some time, so we call it on a background thread. final AudioTrack toRelease = audioTrack; audioTrack = null; @@ -1229,6 +1252,39 @@ public final class DefaultAudioSink implements AudioSink { audioFormat, audioAttributes.getAudioAttributesV21()); } + @EnsuresNonNullIf( + result = true, + expression = {"offloadStreamEventCallback"}) + private static boolean isOffloadedPlayback(AudioTrack audioTrack) { + return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); + } + + @RequiresApi(29) + private final class StreamEventCallback extends AudioTrack.StreamEventCallback { + private final Handler handler; + + public StreamEventCallback() { + handler = new Handler(); + } + + @Override + public void onDataRequest(AudioTrack track, int size) { + Assertions.checkState(track == DefaultAudioSink.this.audioTrack); + if (listener != null) { + listener.onOffloadBufferEmptying(); + } + } + + public void register(AudioTrack audioTrack) { + audioTrack.registerStreamEventCallback(handler::post, this); + } + + public void unregister(AudioTrack audioTrack) { + audioTrack.unregisterStreamEventCallback(this); + handler.removeCallbacksAndMessages(/* token= */ null); + } + } + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index c98bd9bbb9..2e6dc79afb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -91,6 +91,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean allowFirstBufferPositionDiscontinuity; private boolean allowPositionDiscontinuity; + @Nullable private WakeupListener wakeupListener; + /** * @param context A context. * @param mediaCodecSelector A decoder selector. @@ -695,6 +697,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case MSG_SET_AUDIO_SESSION_ID: audioSink.setAudioSessionId((Integer) message); break; + case MSG_SET_WAKEUP_LISTENER: + this.wakeupListener = (WakeupListener) message; + break; default: super.handleMessage(messageType, message); break; @@ -874,5 +879,19 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } + + @Override + public void onOffloadBufferEmptying() { + if (wakeupListener != null) { + wakeupListener.onWakeup(); + } + } + + @Override + public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { + if (wakeupListener != null) { + wakeupListener.onSleep(bufferEmptyingDeadlineMs); + } + } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index b4678cb7cf..c79a128f81 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -465,4 +465,9 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { public boolean getPauseAtEndOfMediaItems() { throw new UnsupportedOperationException(); } + + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + throw new UnsupportedOperationException(); + } } From e111f850d034652a13fcae510e407daf94b350e5 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 11 Jun 2020 11:17:43 +0100 Subject: [PATCH 0458/1052] Allow skipping the ad before the start position PiperOrigin-RevId: 315867160 --- RELEASENOTES.md | 1 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 112 ++++++++--- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 182 +++++++++++++++++- 3 files changed, 260 insertions(+), 35 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b60ffc169c..c1795b6a12 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -205,6 +205,7 @@ ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue manipulation API. +* IMA extension: Add option to skip ads before the start position. * Demo app: Retain previous position in list of samples. * Add Guava dependency. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 51c3894a19..ad6a34e84f 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -123,6 +123,7 @@ public final class ImaAdsLoader private int mediaLoadTimeoutMs; private int mediaBitrate; private boolean focusSkipButtonWhenAvailable; + private boolean playAdBeforeStartPosition; private ImaFactory imaFactory; /** @@ -137,6 +138,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; focusSkipButtonWhenAvailable = true; + playAdBeforeStartPosition = true; imaFactory = new DefaultImaFactory(); } @@ -250,6 +252,21 @@ public final class ImaAdsLoader return this; } + /** + * Sets whether to play an ad before the start position when beginning playback. If {@code + * true}, an ad will be played if there is one at or before the start position. If {@code + * false}, an ad will be played only if there is one exactly at the start position. The default + * setting is {@code true}. + * + * @param playAdBeforeStartPosition Whether to play an ad before the start position when + * beginning playback. + * @return This builder, for convenience. + */ + public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { + this.playAdBeforeStartPosition = playAdBeforeStartPosition; + return this; + } + @VisibleForTesting /* package */ Builder setImaFactory(ImaFactory imaFactory) { this.imaFactory = Assertions.checkNotNull(imaFactory); @@ -275,6 +292,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, adEventListener, imaFactory); @@ -298,6 +316,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, adEventListener, imaFactory); @@ -360,6 +379,7 @@ public final class ImaAdsLoader private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; + private final boolean playAdBeforeStartPosition; private final int mediaBitrate; @Nullable private final Set adUiElements; @Nullable private final AdEventListener adEventListener; @@ -465,6 +485,7 @@ public final class ImaAdsLoader /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, /* focusSkipButtonWhenAvailable= */ true, + /* playAdBeforeStartPosition= */ true, /* adUiElements= */ null, /* adEventListener= */ null, /* imaFactory= */ new DefaultImaFactory()); @@ -481,6 +502,7 @@ public final class ImaAdsLoader int mediaLoadTimeoutMs, int mediaBitrate, boolean focusSkipButtonWhenAvailable, + boolean playAdBeforeStartPosition, @Nullable Set adUiElements, @Nullable AdEventListener adEventListener, ImaFactory imaFactory) { @@ -492,6 +514,7 @@ public final class ImaAdsLoader this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = playAdBeforeStartPosition; this.adUiElements = adUiElements; this.adEventListener = adEventListener; this.imaFactory = imaFactory; @@ -671,15 +694,7 @@ public final class ImaAdsLoader @Override public void release() { pendingAdRequestContext = null; - if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); - if (adEventListener != null) { - adsManager.removeAdEventListener(adEventListener); - } - adsManager.destroy(); - adsManager = null; - } + destroyAdsManager(); adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; @@ -983,13 +998,18 @@ public final class ImaAdsLoader @Nullable AdsManager adsManager = this.adsManager; if (!isAdsManagerInitialized && adsManager != null) { isAdsManagerInitialized = true; - AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); - adsManager.init(adsRenderingSettings); - adsManager.start(); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } } + updateAdPlaybackState(); } handleTimelineOrPositionChanged(); } @@ -1063,7 +1083,11 @@ public final class ImaAdsLoader // Internal methods. - /** Configures ads rendering for starting playback, returning the settings for the IMA SDK. */ + /** + * Configures ads rendering for starting playback, returning the settings for the IMA SDK or + * {@code null} if no ads should play. + */ + @Nullable private AdsRenderingSettings setupAdsRendering() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); @@ -1083,26 +1107,42 @@ public final class ImaAdsLoader long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; long contentPositionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); - int adGroupIndexForPosition = + int adGroupForPositionIndex = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition != C.INDEX_UNSET) { - // Provide the player's initial position to trigger loading and playing the ad. If there are - // no midrolls, we are playing a preroll and any pending content position wouldn't be cleared. - if (hasMidrollAdGroups(adGroupTimesUs)) { + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + playAdBeforeStartPosition + || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs); + if (!playAdWhenStartingPlayback) { + adGroupForPositionIndex++; + } else if (hasMidrollAdGroups(adGroupTimesUs)) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be + // cleared. pendingContentPositionMs = contentPositionMs; } - if (adGroupIndexForPosition > 0) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { + if (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } - // Play ads after the midpoint between the ad to play and the one before it, to avoid issues - // with rounding one of the two ad times. - long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; - long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; - double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; - adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + if (adGroupForPositionIndex == adGroupTimesUs.length) { + // We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP + // ads, we signal that no ads will render so the caller can destroy the ads manager. + return null; + } + long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex]; + long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1]; + if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) { + // Play the postroll by offsetting the start position just past the last non-postroll ad. + adsRenderingSettings.setPlayAdsAfterTime( + (double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d); + } else { + // Play ads after the midpoint between the ad to play and the one before it, to avoid + // issues with rounding one of the two ad times. + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + } } } return adsRenderingSettings; @@ -1552,6 +1592,18 @@ public final class ImaAdsLoader } } + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(this); + adsManager.removeAdEventListener(this); + if (adEventListener != null) { + adsManager.removeAdEventListener(adEventListener); + } + adsManager.destroy(); + adsManager = null; + } + } + /** Factory for objects provided by the IMA SDK. */ @VisibleForTesting /* package */ interface ImaFactory { diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index d92417c2de..906dfeb5fb 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -453,6 +453,171 @@ public final class ImaAdsLoaderTest { .withSkippedAdGroup(/* adGroupIndex= */ 0)); } + @Test + public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMidroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsManager).destroy(); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withSkippedAdGroup(/* adGroupIndex= */ 1)); + } + + @Test + public void + resumePlaybackBeforeSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); @@ -466,14 +631,21 @@ public final class ImaAdsLoaderTest { } private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { - fakeExoPlayer = new FakePlayer(); - adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); - when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); - imaAdsLoader = + setupPlayback( + contentTimeline, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI); + .buildForAdTag(TEST_URI)); + } + + private void setupPlayback( + Timeline contentTimeline, Float[] cuePoints, ImaAdsLoader imaAdsLoader) { + fakeExoPlayer = new FakePlayer(); + adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); + when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); } From 285cce629f827ab51a59442e1d25b021f4e3ecdf Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 11 Jun 2020 12:20:30 +0100 Subject: [PATCH 0459/1052] Rename DedicatedThreadAsyncMediaCodecAdapter Rename the DedicatedThreadAsyncMediaCodecAdapter to AsynchronousMediaCodecAdapter as it is the only asynchronous adapter implementation left after the clean-up. PiperOrigin-RevId: 315873431 --- ...decAdapter.java => AsynchronousMediaCodecAdapter.java} | 8 ++++---- .../android/exoplayer2/mediacodec/MediaCodecRenderer.java | 4 ++-- ...erTest.java => AsynchronousMediaCodecAdapterTest.java} | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) rename library/core/src/main/java/com/google/android/exoplayer2/mediacodec/{DedicatedThreadAsyncMediaCodecAdapter.java => AsynchronousMediaCodecAdapter.java} (96%) rename library/core/src/test/java/com/google/android/exoplayer2/mediacodec/{DedicatedThreadAsyncMediaCodecAdapterTest.java => AsynchronousMediaCodecAdapterTest.java} (98%) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java similarity index 96% rename from library/core/src/main/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapter.java rename to library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java index 88e3f56daa..35d3989c29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java @@ -40,7 +40,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; *

        This adapter supports queueing input buffers asynchronously. */ @RequiresApi(23) -/* package */ final class DedicatedThreadAsyncMediaCodecAdapter extends MediaCodec.Callback +/* package */ final class AsynchronousMediaCodecAdapter extends MediaCodec.Callback implements MediaCodecAdapter { @Documented @@ -70,7 +70,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for * labelling the internal thread accordingly. */ - /* package */ DedicatedThreadAsyncMediaCodecAdapter(MediaCodec codec, int trackType) { + /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { this( codec, /* enableAsynchronousQueueing= */ false, @@ -86,7 +86,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for * labelling the internal thread accordingly. */ - /* package */ DedicatedThreadAsyncMediaCodecAdapter( + /* package */ AsynchronousMediaCodecAdapter( MediaCodec codec, boolean enableAsynchronousQueueing, int trackType) { this( codec, @@ -96,7 +96,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } @VisibleForTesting - /* package */ DedicatedThreadAsyncMediaCodecAdapter( + /* package */ AsynchronousMediaCodecAdapter( MediaCodec codec, boolean enableAsynchronousQueueing, int trackType, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 27f0621cfc..21cc04ec23 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -1075,12 +1075,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codec = MediaCodec.createByCodecName(codecName); if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD && Util.SDK_INT >= 23) { - codecAdapter = new DedicatedThreadAsyncMediaCodecAdapter(codec, getTrackType()); + codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING && Util.SDK_INT >= 23) { codecAdapter = - new DedicatedThreadAsyncMediaCodecAdapter( + new AsynchronousMediaCodecAdapter( codec, /* enableAsynchronousQueueing= */ true, getTrackType()); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java similarity index 98% rename from library/core/src/test/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapterTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java index 7f61d7d67e..ee6f8690e2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java @@ -40,11 +40,11 @@ import org.robolectric.Shadows; import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowLooper; -/** Unit tests for {@link DedicatedThreadAsyncMediaCodecAdapter}. */ +/** Unit tests for {@link AsynchronousMediaCodecAdapter}. */ @LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) -public class DedicatedThreadAsyncMediaCodecAdapterTest { - private DedicatedThreadAsyncMediaCodecAdapter adapter; +public class AsynchronousMediaCodecAdapterTest { + private AsynchronousMediaCodecAdapter adapter; private MediaCodec codec; private TestHandlerThread handlerThread; private MediaCodec.BufferInfo bufferInfo; @@ -54,7 +54,7 @@ public class DedicatedThreadAsyncMediaCodecAdapterTest { codec = MediaCodec.createByCodecName("h264"); handlerThread = new TestHandlerThread("TestHandlerThread"); adapter = - new DedicatedThreadAsyncMediaCodecAdapter( + new AsynchronousMediaCodecAdapter( codec, /* enableAsynchronousQueueing= */ false, /* trackType= */ C.TRACK_TYPE_VIDEO, From 2aac0717d728df5511ebac5855467e83cd2d4aa0 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 11 Jun 2020 12:59:31 +0100 Subject: [PATCH 0460/1052] Propagate format in supportsOutput It is needed to know if gapless is needed, as gapless offload might not be supported. PiperOrigin-RevId: 315877127 --- .../ext/ffmpeg/FfmpegAudioRenderer.java | 7 ++----- .../ext/flac/LibflacAudioRenderer.java | 2 +- .../ext/opus/LibopusAudioRenderer.java | 2 +- .../android/exoplayer2/audio/AudioSink.java | 7 ++++--- .../audio/DecoderAudioRenderer.java | 7 +++---- .../exoplayer2/audio/DefaultAudioSink.java | 16 +++++++++----- .../exoplayer2/audio/ForwardingAudioSink.java | 7 ++++--- .../audio/MediaCodecAudioRenderer.java | 12 +++++------ .../audio/DefaultAudioSinkTest.java | 21 ++++++++----------- 9 files changed, 40 insertions(+), 41 deletions(-) diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index f5e5281886..7d53c519a7 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -142,15 +142,12 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { } private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) - || supportsOutput(inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_16BIT); + return shouldUseFloatOutput(inputFormat) || supportsOutput(inputFormat, C.ENCODING_PCM_16BIT); } private boolean shouldUseFloatOutput(Format inputFormat) { Assertions.checkNotNull(inputFormat.sampleMimeType); - if (!enableFloatOutput - || !supportsOutput( - inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_FLOAT)) { + if (!enableFloatOutput || !supportsOutput(inputFormat, C.ENCODING_PCM_FLOAT)) { return false; } switch (inputFormat.sampleMimeType) { diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 9315c302cc..24a247fc76 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -100,7 +100,7 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset); pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample); } - if (!supportsOutput(format.channelCount, format.sampleRate, pcmEncoding)) { + if (!supportsOutput(format, pcmEncoding)) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { return FORMAT_UNSUPPORTED_DRM; diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index 6fe1fa8895..cafa337cf2 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -69,7 +69,7 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { if (!OpusLibrary.isAvailable() || !MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!supportsOutput(format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { + } else if (!supportsOutput(format, C.ENCODING_PCM_16BIT)) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (!drmIsSupported) { return FORMAT_UNSUPPORTED_DRM; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 8bebd97a67..8d1fa0cc4f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.audio; import android.media.AudioTrack; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.Encoding; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -187,12 +188,12 @@ public interface AudioSink { /** * Returns whether the sink supports the audio format. * - * @param channelCount The number of channels, or {@link Format#NO_VALUE} if not known. - * @param sampleRate The sample rate, or {@link Format#NO_VALUE} if not known. + * @param format The format of the audio. {@link Format#pcmEncoding} is ignored and the {@code + * encoding} argument is used instead. * @param encoding The audio encoding, or {@link Format#NO_VALUE} if not known. * @return Whether the sink supports the audio format. */ - boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding); + boolean supportsOutput(Format format, @Encoding int encoding); /** * Returns the playback position in the stream starting at zero, in microseconds, or diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index 9f1fe07c39..72a49caa29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -214,11 +214,10 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media /** * Returns whether the sink supports the audio format. * - * @see AudioSink#supportsOutput(int, int, int) + * @see AudioSink#supportsOutput(Format, int) */ - protected final boolean supportsOutput( - int channelCount, int sampleRateHz, @C.Encoding int encoding) { - return audioSink.supportsOutput(channelCount, sampleRateHz, encoding); + protected final boolean supportsOutput(Format format, @C.Encoding int encoding) { + return audioSink.supportsOutput(format, encoding); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 880aefdbbd..425d786380 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -421,7 +421,7 @@ public final class DefaultAudioSink implements AudioSink { } @Override - public boolean supportsOutput(int channelCount, int sampleRateHz, @C.Encoding int encoding) { + public boolean supportsOutput(Format format, @C.Encoding int encoding) { if (encoding == C.ENCODING_INVALID) { return false; } @@ -433,10 +433,11 @@ public final class DefaultAudioSink implements AudioSink { return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; } if (enableOffload - && isOffloadedPlaybackSupported(channelCount, sampleRateHz, encoding, audioAttributes)) { + && isOffloadedPlaybackSupported( + format.channelCount, format.sampleRate, encoding, audioAttributes)) { return true; } - return isPassthroughPlaybackSupported(encoding, channelCount); + return isPassthroughPlaybackSupported(encoding, format.channelCount); } @Override @@ -475,8 +476,13 @@ public final class DefaultAudioSink implements AudioSink { @C.Encoding int encoding = inputEncoding; boolean useFloatOutput = enableFloatOutput - && supportsOutput(inputChannelCount, inputSampleRate, C.ENCODING_PCM_FLOAT) - && Util.isEncodingHighResolutionPcm(inputEncoding); + && Util.isEncodingHighResolutionPcm(inputEncoding) + && supportsOutput( + new Format.Builder() + .setChannelCount(inputChannelCount) + .setSampleRate(inputSampleRate) + .build(), + C.ENCODING_PCM_FLOAT); AudioProcessor[] availableAudioProcessors = useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; if (processingEnabled) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java index e0703f2aa3..f01b55a3f1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -16,7 +16,8 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.Encoding; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -35,8 +36,8 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding) { - return sink.supportsOutput(channelCount, sampleRate, encoding); + public boolean supportsOutput(Format format, @Encoding int encoding) { + return sink.supportsOutput(format, encoding); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 2e6dc79afb..f953d38866 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -226,10 +226,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - && !audioSink.supportsOutput( - format.channelCount, format.sampleRate, format.pcmEncoding)) - || !audioSink.supportsOutput( - format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { + && !audioSink.supportsOutput(format, format.pcmEncoding)) + || !audioSink.supportsOutput(format, C.ENCODING_PCM_16BIT)) { // Assume the decoder outputs 16-bit PCM, unless the input is raw. return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); } @@ -463,8 +461,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { // E-AC3 JOC is object-based so the output channel count is arbitrary. - if (audioSink.supportsOutput( - /* channelCount= */ Format.NO_VALUE, format.sampleRate, C.ENCODING_E_AC3_JOC)) { + Format eAc3JocFormat = format.buildUpon().setChannelCount(Format.NO_VALUE).build(); + if (audioSink.supportsOutput(eAc3JocFormat, C.ENCODING_E_AC3_JOC)) { return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC, format.codecs); } // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. @@ -472,7 +470,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @C.Encoding int encoding = MimeTypes.getEncoding(mimeType, format.codecs); - if (audioSink.supportsOutput(format.channelCount, format.sampleRate, encoding)) { + if (audioSink.supportsOutput(format, encoding)) { return encoding; } else { return C.ENCODING_INVALID; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index e916ca549f..7102bcd5ea 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -21,6 +21,7 @@ import static org.robolectric.annotation.Config.TARGET_SDK; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; @@ -50,6 +51,11 @@ public final class DefaultAudioSinkTest { private static final int SAMPLE_RATE_44_1 = 44100; private static final int TRIM_100_MS_FRAME_COUNT = 4410; private static final int TRIM_10_MS_FRAME_COUNT = 441; + private static final Format STEREO_44_1_FORMAT = + new Format.Builder() + .setChannelCount(CHANNEL_COUNT_STEREO) + .setSampleRate(SAMPLE_RATE_44_1) + .build(); private DefaultAudioSink defaultAudioSink; private ArrayAudioBufferSink arrayAudioBufferSink; @@ -201,19 +207,13 @@ public final class DefaultAudioSinkTest { @Config(minSdk = OLDEST_SDK, maxSdk = 20) @Test public void doesNotSupportFloatOutputBeforeApi21() { - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) - .isFalse(); + assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isFalse(); } @Config(minSdk = 21, maxSdk = TARGET_SDK) @Test public void supportsFloatOutputFromApi21() { - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) - .isTrue(); + assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isTrue(); } @Test @@ -221,10 +221,7 @@ public final class DefaultAudioSinkTest { DefaultAudioSink defaultAudioSink = new DefaultAudioSink( new AudioCapabilities(new int[] {C.ENCODING_AAC_LC}, 2), new AudioProcessor[0]); - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_AAC_LC)) - .isFalse(); + assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_AAC_LC)).isFalse(); } private void configureDefaultAudioSink(int channelCount) throws AudioSink.ConfigurationException { From 0b608dd19c872f40e3907bc8ad20749e9596fc28 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 11 Jun 2020 14:17:40 +0100 Subject: [PATCH 0461/1052] Fix order of events in ProgressiveMediaPeriod. The order of source info refresh and onPrepared was accidentally changed by https://github.com/google/ExoPlayer/commit/ed88f4f1dd7addc9932094113f560287ea7d344e. This changes it back to the correct order and adds a test PiperOrigin-RevId: 315885164 --- .../source/ProgressiveMediaPeriod.java | 6 +- .../source/ProgressiveMediaPeriodTest.java | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 1e48d95bee..d879671c83 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -734,13 +734,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private void setSeekMap(SeekMap seekMap) { this.seekMap = icyHeaders == null ? seekMap : new Unseekable(/* durationUs= */ C.TIME_UNSET); - if (!prepared) { - maybeFinishPrepare(); - } durationUs = seekMap.getDurationUs(); isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; dataType = isLive ? C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE : C.DATA_TYPE_MEDIA; listener.onSourceInfoRefreshed(durationUs, seekMap.isSeekable(), isLive); + if (!prepared) { + maybeFinishPrepare(); + } } private void maybeFinishPrepare() { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java new file mode 100644 index 0000000000..0478f77367 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; +import com.google.android.exoplayer2.testutil.TestExoPlayer; +import com.google.android.exoplayer2.upstream.AssetDataSource; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.LooperMode; + +/** Unit test for {@link ProgressiveMediaPeriod}. */ +@RunWith(AndroidJUnit4.class) +@LooperMode(LooperMode.Mode.PAUSED) +public final class ProgressiveMediaPeriodTest { + + @Test + public void prepare_updatesSourceInfoBeforeOnPreparedCallback() throws Exception { + AtomicBoolean sourceInfoRefreshCalled = new AtomicBoolean(false); + ProgressiveMediaPeriod.Listener sourceInfoRefreshListener = + (durationUs, isSeekable, isLive) -> sourceInfoRefreshCalled.set(true); + ProgressiveMediaPeriod mediaPeriod = + new ProgressiveMediaPeriod( + Uri.parse("asset://android_asset/mp4/sample.mp4"), + new AssetDataSource(ApplicationProvider.getApplicationContext()), + () -> new Extractor[] {new Mp4Extractor()}, + DrmSessionManager.DUMMY, + new DefaultLoadErrorHandlingPolicy(), + new MediaSourceEventListener.EventDispatcher(), + sourceInfoRefreshListener, + new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + /* customCacheKey= */ null, + ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES); + + AtomicBoolean prepareCallbackCalled = new AtomicBoolean(false); + AtomicBoolean sourceInfoRefreshCalledBeforeOnPrepared = new AtomicBoolean(false); + mediaPeriod.prepare( + new MediaPeriod.Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + sourceInfoRefreshCalledBeforeOnPrepared.set(sourceInfoRefreshCalled.get()); + prepareCallbackCalled.set(true); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + source.continueLoading(/* positionUs= */ 0); + } + }, + /* positionUs= */ 0); + TestExoPlayer.runUntil(prepareCallbackCalled::get); + mediaPeriod.release(); + + assertThat(sourceInfoRefreshCalledBeforeOnPrepared.get()).isTrue(); + } +} From 962e08d3be3b47166d1628cd1951e115c5cc00be Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 11 Jun 2020 14:47:09 +0100 Subject: [PATCH 0462/1052] Add Offload gapless support Confirmed to work on a Pixel 4 after enabling the feature: `setprop vendor.audio.offload.gapless.enabled true` PiperOrigin-RevId: 315889054 --- .../exoplayer2/audio/DefaultAudioSink.java | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 425d786380..e05c7caff7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -434,7 +434,12 @@ public final class DefaultAudioSink implements AudioSink { } if (enableOffload && isOffloadedPlaybackSupported( - format.channelCount, format.sampleRate, encoding, audioAttributes)) { + format.channelCount, + format.sampleRate, + encoding, + audioAttributes, + format.encoderDelay, + format.encoderPadding)) { return true; } return isPassthroughPlaybackSupported(encoding, format.channelCount); @@ -518,7 +523,13 @@ public final class DefaultAudioSink implements AudioSink { boolean useOffload = enableOffload && !isInputPcm - && isOffloadedPlaybackSupported(channelCount, sampleRate, encoding, audioAttributes); + && isOffloadedPlaybackSupported( + channelCount, + sampleRate, + encoding, + audioAttributes, + trimStartFrames, + trimEndFrames); Configuration pendingConfiguration = new Configuration( @@ -533,6 +544,8 @@ public final class DefaultAudioSink implements AudioSink { processingEnabled, canApplyPlaybackParameters, availableAudioProcessors, + trimStartFrames, + trimEndFrames, useOffload); if (isInitialized()) { this.pendingConfiguration = pendingConfiguration; @@ -578,7 +591,8 @@ public final class DefaultAudioSink implements AudioSink { .buildAudioTrack(tunneling, audioAttributes, audioSessionId); if (isOffloadedPlayback(audioTrack)) { // Receive stream event callbacks on the current (playback) thread. - Assertions.checkNotNull(offloadStreamEventCallback).register(audioTrack); + offloadStreamEventCallback.register(audioTrack); + audioTrack.setOffloadDelayPadding(configuration.trimStartFrames, configuration.trimEndFrames); } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { @@ -656,6 +670,11 @@ public final class DefaultAudioSink implements AudioSink { // The current audio track can be reused for the new configuration. configuration = pendingConfiguration; pendingConfiguration = null; + if (isOffloadedPlayback(audioTrack)) { + audioTrack.setOffloadEndOfStream(); + audioTrack.setOffloadDelayPadding( + configuration.trimStartFrames, configuration.trimEndFrames); + } } // Re-apply playback parameters. applyPlaybackSpeedAndSkipSilence(presentationTimeUs); @@ -1248,14 +1267,24 @@ public final class DefaultAudioSink implements AudioSink { int channelCount, int sampleRateHz, @C.Encoding int encoding, - AudioAttributes audioAttributes) { + AudioAttributes audioAttributes, + int trimStartFrames, + int trimEndFrames) { if (Util.SDK_INT < 29) { return false; } int channelMask = getChannelConfig(channelCount, /* isInputPcm= */ false); AudioFormat audioFormat = getAudioFormat(sampleRateHz, channelMask, encoding); - return AudioManager.isOffloadedPlaybackSupported( - audioFormat, audioAttributes.getAudioAttributesV21()); + if (!AudioManager.isOffloadedPlaybackSupported( + audioFormat, audioAttributes.getAudioAttributesV21())) { + return false; + } + if (trimStartFrames > 0 || trimEndFrames > 0) { + // TODO(internal b/158191844): Gapless offload is not supported by all devices and there is no + // API to query its support. + return false; + } + return true; } @EnsuresNonNullIf( @@ -1575,6 +1604,8 @@ public final class DefaultAudioSink implements AudioSink { public final boolean processingEnabled; public final boolean canApplyPlaybackParameters; public final AudioProcessor[] availableAudioProcessors; + public int trimStartFrames; + public int trimEndFrames; public final boolean useOffload; public Configuration( @@ -1589,6 +1620,8 @@ public final class DefaultAudioSink implements AudioSink { boolean processingEnabled, boolean canApplyPlaybackParameters, AudioProcessor[] availableAudioProcessors, + int trimStartFrames, + int trimEndFrames, boolean useOffload) { this.isInputPcm = isInputPcm; this.inputPcmFrameSize = inputPcmFrameSize; @@ -1600,6 +1633,8 @@ public final class DefaultAudioSink implements AudioSink { this.processingEnabled = processingEnabled; this.canApplyPlaybackParameters = canApplyPlaybackParameters; this.availableAudioProcessors = availableAudioProcessors; + this.trimStartFrames = trimStartFrames; + this.trimEndFrames = trimEndFrames; this.useOffload = useOffload; // Call computeBufferSize() last as it depends on the other configuration values. From 73283d495a4864a640ed45f4044c55aca3c71365 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 11 Jun 2020 16:31:49 +0100 Subject: [PATCH 0463/1052] Add test that asserts correct offsets are used in renderer. The test uses two items with period-in-window offsets and a non-zero default start position. The test also prepares the first item lazily so that the start position (and thus the renderer offsets) need to change. This is arguably the most complicated setup that needs to be tested. PiperOrigin-RevId: 315903958 --- .../android/exoplayer2/ExoPlayerTest.java | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 5fc65a678b..e02af5ffea 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -88,6 +88,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -6615,6 +6616,85 @@ public final class ExoPlayerTest { .inOrder(); } + /** + * This tests that renderer offsets and buffer times in the renderer are set correctly even when + * the sources have a window-to-period offset and a non-zero default start position. The start + * offset of the first source is also updated during preparation to make sure the player adapts + * everything accordingly. + */ + @Test + public void + playlistWithMediaWithStartOffsets_andStartOffsetChangesDuringPreparation_appliesCorrectRenderingOffsetToAllPeriods() + throws Exception { + List rendererStreamOffsetsUs = new ArrayList<>(); + List firstBufferTimesUsWithOffset = new ArrayList<>(); + FakeRenderer renderer = + new FakeRenderer(C.TRACK_TYPE_VIDEO) { + boolean pendingFirstBufferTime = false; + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) { + rendererStreamOffsetsUs.add(offsetUs); + pendingFirstBufferTime = true; + } + + @Override + protected boolean shouldProcessBuffer(long bufferTimeUs, long playbackPositionUs) { + if (pendingFirstBufferTime) { + firstBufferTimesUsWithOffset.add(bufferTimeUs); + pendingFirstBufferTime = false; + } + return super.shouldProcessBuffer(bufferTimeUs, playbackPositionUs); + } + }; + Timeline timelineWithOffsets = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ new Object(), + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US, + /* defaultPositionUs= */ 4_567_890, + /* windowOffsetInFirstPeriodUs= */ 1_234_567, + AdPlaybackState.NONE)); + ExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + FakeMediaSource firstMediaSource = + new FakeMediaSource(/* timeline= */ null, ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource secondMediaSource = + new FakeMediaSource(timelineWithOffsets, ExoPlayerTestRunner.VIDEO_FORMAT); + player.setMediaSources(ImmutableList.of(firstMediaSource, secondMediaSource)); + + // Start playback and wait until player is idly waiting for an update of the first source. + player.prepare(); + player.play(); + TestExoPlayer.runUntilPendingCommandsAreFullyHandled(player); + // Update media with a non-zero default start position and window offset. + firstMediaSource.setNewSourceInfo(timelineWithOffsets); + // Wait until player transitions to second source (which also has non-zero offsets). + TestExoPlayer.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + player.release(); + + assertThat(rendererStreamOffsetsUs).hasSize(2); + assertThat(firstBufferTimesUsWithOffset).hasSize(2); + // Assert that the offsets and buffer times match the expected sample time. + long firstSampleTimeUs = 4_567_890 + 1_234_567; + assertThat(firstBufferTimesUsWithOffset.get(0)) + .isEqualTo(rendererStreamOffsetsUs.get(0) + firstSampleTimeUs); + assertThat(firstBufferTimesUsWithOffset.get(1)) + .isEqualTo(rendererStreamOffsetsUs.get(1) + firstSampleTimeUs); + // Assert that the second source continues rendering seamlessly at the point where the first one + // ended. + long periodDurationUs = + timelineWithOffsets.getPeriod(/* periodIndex= */ 0, new Timeline.Period()).durationUs; + assertThat(firstBufferTimesUsWithOffset.get(1)) + .isEqualTo(rendererStreamOffsetsUs.get(0) + periodDurationUs); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { From 5324dc37e3175cf6ed95d1a5793878f3c04ef5b9 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 11 Jun 2020 19:31:29 +0100 Subject: [PATCH 0464/1052] Support passing custom manifest parsers to Downloaders Issue: #5978 PiperOrigin-RevId: 315941765 --- .../exoplayer2/offline/SegmentDownloader.java | 2 +- .../source/dash/offline/DashDownloader.java | 22 ++++++++++++++++++- .../source/hls/offline/HlsDownloader.java | 22 ++++++++++++++++++- .../smoothstreaming/offline/SsDownloader.java | 22 ++++++++++++++++++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index f5abd3228e..7360b65f70 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -82,7 +82,7 @@ public abstract class SegmentDownloader> impleme /** * @param mediaItem The {@link MediaItem} to be downloaded. - * @param manifestParser A parser for the manifest. + * @param manifestParser A parser for manifests belonging to the media to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java index a367c73747..31a1f84674 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java @@ -34,6 +34,7 @@ import com.google.android.exoplayer2.source.dash.manifest.RangedUri; import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import java.io.IOException; import java.util.ArrayList; @@ -115,7 +116,26 @@ public final class DashDownloader extends SegmentDownloader { */ public DashDownloader( MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - super(mediaItem, new DashManifestParser(), cacheDataSourceFactory, executor); + this(mediaItem, new DashManifestParser(), cacheDataSourceFactory, executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for DASH manifests. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + * @param executor An {@link Executor} used to make requests for the media being downloaded. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. + */ + public DashDownloader( + MediaItem mediaItem, + Parser manifestParser, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + super(mediaItem, manifestParser, cacheDataSourceFactory, executor); } @Override diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java index 2b604ee8be..858fe8f527 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.util.UriUtil; import java.io.IOException; @@ -110,7 +111,26 @@ public final class HlsDownloader extends SegmentDownloader { */ public HlsDownloader( MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - super(mediaItem, new HlsPlaylistParser(), cacheDataSourceFactory, executor); + this(mediaItem, new HlsPlaylistParser(), cacheDataSourceFactory, executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for HLS playlists. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + * @param executor An {@link Executor} used to make requests for the media being downloaded. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. + */ + public HlsDownloader( + MediaItem mediaItem, + Parser manifestParser, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + super(mediaItem, manifestParser, cacheDataSourceFactory, executor); } @Override diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java index bb1bb06e6c..05828703f6 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestP import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsUtil; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import java.util.ArrayList; import java.util.List; @@ -108,7 +109,7 @@ public final class SsDownloader extends SegmentDownloader { */ public SsDownloader( MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - super( + this( mediaItem .buildUpon() .setUri(SsUtil.fixManifestUri(checkNotNull(mediaItem.playbackProperties).uri)) @@ -118,6 +119,25 @@ public final class SsDownloader extends SegmentDownloader { executor); } + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for SmoothStreaming manifests. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + * @param executor An {@link Executor} used to make requests for the media being downloaded. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. + */ + public SsDownloader( + MediaItem mediaItem, + Parser manifestParser, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + super(mediaItem, manifestParser, cacheDataSourceFactory, executor); + } + @Override protected List getSegments( DataSource dataSource, SsManifest manifest, boolean allowIncompleteList) { From 8afc0c3424d19263b950a25beac9a44855bab757 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 11 Jun 2020 19:55:10 +0100 Subject: [PATCH 0465/1052] Rollback of https://github.com/google/ExoPlayer/commit/962e08d3be3b47166d1628cd1951e115c5cc00be *** Original commit *** Add Offload gapless support Confirmed to work on a Pixel 4 after enabling the feature: `setprop vendor.audio.offload.gapless.enabled true` *** PiperOrigin-RevId: 315946947 --- .../exoplayer2/audio/DefaultAudioSink.java | 47 +++---------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index e05c7caff7..425d786380 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -434,12 +434,7 @@ public final class DefaultAudioSink implements AudioSink { } if (enableOffload && isOffloadedPlaybackSupported( - format.channelCount, - format.sampleRate, - encoding, - audioAttributes, - format.encoderDelay, - format.encoderPadding)) { + format.channelCount, format.sampleRate, encoding, audioAttributes)) { return true; } return isPassthroughPlaybackSupported(encoding, format.channelCount); @@ -523,13 +518,7 @@ public final class DefaultAudioSink implements AudioSink { boolean useOffload = enableOffload && !isInputPcm - && isOffloadedPlaybackSupported( - channelCount, - sampleRate, - encoding, - audioAttributes, - trimStartFrames, - trimEndFrames); + && isOffloadedPlaybackSupported(channelCount, sampleRate, encoding, audioAttributes); Configuration pendingConfiguration = new Configuration( @@ -544,8 +533,6 @@ public final class DefaultAudioSink implements AudioSink { processingEnabled, canApplyPlaybackParameters, availableAudioProcessors, - trimStartFrames, - trimEndFrames, useOffload); if (isInitialized()) { this.pendingConfiguration = pendingConfiguration; @@ -591,8 +578,7 @@ public final class DefaultAudioSink implements AudioSink { .buildAudioTrack(tunneling, audioAttributes, audioSessionId); if (isOffloadedPlayback(audioTrack)) { // Receive stream event callbacks on the current (playback) thread. - offloadStreamEventCallback.register(audioTrack); - audioTrack.setOffloadDelayPadding(configuration.trimStartFrames, configuration.trimEndFrames); + Assertions.checkNotNull(offloadStreamEventCallback).register(audioTrack); } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { @@ -670,11 +656,6 @@ public final class DefaultAudioSink implements AudioSink { // The current audio track can be reused for the new configuration. configuration = pendingConfiguration; pendingConfiguration = null; - if (isOffloadedPlayback(audioTrack)) { - audioTrack.setOffloadEndOfStream(); - audioTrack.setOffloadDelayPadding( - configuration.trimStartFrames, configuration.trimEndFrames); - } } // Re-apply playback parameters. applyPlaybackSpeedAndSkipSilence(presentationTimeUs); @@ -1267,24 +1248,14 @@ public final class DefaultAudioSink implements AudioSink { int channelCount, int sampleRateHz, @C.Encoding int encoding, - AudioAttributes audioAttributes, - int trimStartFrames, - int trimEndFrames) { + AudioAttributes audioAttributes) { if (Util.SDK_INT < 29) { return false; } int channelMask = getChannelConfig(channelCount, /* isInputPcm= */ false); AudioFormat audioFormat = getAudioFormat(sampleRateHz, channelMask, encoding); - if (!AudioManager.isOffloadedPlaybackSupported( - audioFormat, audioAttributes.getAudioAttributesV21())) { - return false; - } - if (trimStartFrames > 0 || trimEndFrames > 0) { - // TODO(internal b/158191844): Gapless offload is not supported by all devices and there is no - // API to query its support. - return false; - } - return true; + return AudioManager.isOffloadedPlaybackSupported( + audioFormat, audioAttributes.getAudioAttributesV21()); } @EnsuresNonNullIf( @@ -1604,8 +1575,6 @@ public final class DefaultAudioSink implements AudioSink { public final boolean processingEnabled; public final boolean canApplyPlaybackParameters; public final AudioProcessor[] availableAudioProcessors; - public int trimStartFrames; - public int trimEndFrames; public final boolean useOffload; public Configuration( @@ -1620,8 +1589,6 @@ public final class DefaultAudioSink implements AudioSink { boolean processingEnabled, boolean canApplyPlaybackParameters, AudioProcessor[] availableAudioProcessors, - int trimStartFrames, - int trimEndFrames, boolean useOffload) { this.isInputPcm = isInputPcm; this.inputPcmFrameSize = inputPcmFrameSize; @@ -1633,8 +1600,6 @@ public final class DefaultAudioSink implements AudioSink { this.processingEnabled = processingEnabled; this.canApplyPlaybackParameters = canApplyPlaybackParameters; this.availableAudioProcessors = availableAudioProcessors; - this.trimStartFrames = trimStartFrames; - this.trimEndFrames = trimEndFrames; this.useOffload = useOffload; // Call computeBufferSize() last as it depends on the other configuration values. From fc0e0d4cb8f4e89431b1d344dce16559fbc97c97 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 11 Jun 2020 19:59:50 +0100 Subject: [PATCH 0466/1052] Rollback of https://github.com/google/ExoPlayer/commit/2aac0717d728df5511ebac5855467e83cd2d4aa0 *** Original commit *** Propagate format in supportsOutput It is needed to know if gapless is needed, as gapless offload might not be supported. *** PiperOrigin-RevId: 315947888 --- .../ext/ffmpeg/FfmpegAudioRenderer.java | 7 +++++-- .../ext/flac/LibflacAudioRenderer.java | 2 +- .../ext/opus/LibopusAudioRenderer.java | 2 +- .../android/exoplayer2/audio/AudioSink.java | 7 +++---- .../audio/DecoderAudioRenderer.java | 7 ++++--- .../exoplayer2/audio/DefaultAudioSink.java | 16 +++++--------- .../exoplayer2/audio/ForwardingAudioSink.java | 7 +++---- .../audio/MediaCodecAudioRenderer.java | 12 ++++++----- .../audio/DefaultAudioSinkTest.java | 21 +++++++++++-------- 9 files changed, 41 insertions(+), 40 deletions(-) diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index 7d53c519a7..f5e5281886 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -142,12 +142,15 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { } private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) || supportsOutput(inputFormat, C.ENCODING_PCM_16BIT); + return shouldUseFloatOutput(inputFormat) + || supportsOutput(inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_16BIT); } private boolean shouldUseFloatOutput(Format inputFormat) { Assertions.checkNotNull(inputFormat.sampleMimeType); - if (!enableFloatOutput || !supportsOutput(inputFormat, C.ENCODING_PCM_FLOAT)) { + if (!enableFloatOutput + || !supportsOutput( + inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_FLOAT)) { return false; } switch (inputFormat.sampleMimeType) { diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 24a247fc76..9315c302cc 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -100,7 +100,7 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset); pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample); } - if (!supportsOutput(format, pcmEncoding)) { + if (!supportsOutput(format.channelCount, format.sampleRate, pcmEncoding)) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { return FORMAT_UNSUPPORTED_DRM; diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index cafa337cf2..6fe1fa8895 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -69,7 +69,7 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { if (!OpusLibrary.isAvailable() || !MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!supportsOutput(format, C.ENCODING_PCM_16BIT)) { + } else if (!supportsOutput(format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (!drmIsSupported) { return FORMAT_UNSUPPORTED_DRM; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 8d1fa0cc4f..8bebd97a67 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.audio; import android.media.AudioTrack; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.C.Encoding; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -188,12 +187,12 @@ public interface AudioSink { /** * Returns whether the sink supports the audio format. * - * @param format The format of the audio. {@link Format#pcmEncoding} is ignored and the {@code - * encoding} argument is used instead. + * @param channelCount The number of channels, or {@link Format#NO_VALUE} if not known. + * @param sampleRate The sample rate, or {@link Format#NO_VALUE} if not known. * @param encoding The audio encoding, or {@link Format#NO_VALUE} if not known. * @return Whether the sink supports the audio format. */ - boolean supportsOutput(Format format, @Encoding int encoding); + boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding); /** * Returns the playback position in the stream starting at zero, in microseconds, or diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index 72a49caa29..9f1fe07c39 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -214,10 +214,11 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media /** * Returns whether the sink supports the audio format. * - * @see AudioSink#supportsOutput(Format, int) + * @see AudioSink#supportsOutput(int, int, int) */ - protected final boolean supportsOutput(Format format, @C.Encoding int encoding) { - return audioSink.supportsOutput(format, encoding); + protected final boolean supportsOutput( + int channelCount, int sampleRateHz, @C.Encoding int encoding) { + return audioSink.supportsOutput(channelCount, sampleRateHz, encoding); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 425d786380..880aefdbbd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -421,7 +421,7 @@ public final class DefaultAudioSink implements AudioSink { } @Override - public boolean supportsOutput(Format format, @C.Encoding int encoding) { + public boolean supportsOutput(int channelCount, int sampleRateHz, @C.Encoding int encoding) { if (encoding == C.ENCODING_INVALID) { return false; } @@ -433,11 +433,10 @@ public final class DefaultAudioSink implements AudioSink { return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; } if (enableOffload - && isOffloadedPlaybackSupported( - format.channelCount, format.sampleRate, encoding, audioAttributes)) { + && isOffloadedPlaybackSupported(channelCount, sampleRateHz, encoding, audioAttributes)) { return true; } - return isPassthroughPlaybackSupported(encoding, format.channelCount); + return isPassthroughPlaybackSupported(encoding, channelCount); } @Override @@ -476,13 +475,8 @@ public final class DefaultAudioSink implements AudioSink { @C.Encoding int encoding = inputEncoding; boolean useFloatOutput = enableFloatOutput - && Util.isEncodingHighResolutionPcm(inputEncoding) - && supportsOutput( - new Format.Builder() - .setChannelCount(inputChannelCount) - .setSampleRate(inputSampleRate) - .build(), - C.ENCODING_PCM_FLOAT); + && supportsOutput(inputChannelCount, inputSampleRate, C.ENCODING_PCM_FLOAT) + && Util.isEncodingHighResolutionPcm(inputEncoding); AudioProcessor[] availableAudioProcessors = useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; if (processingEnabled) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java index f01b55a3f1..e0703f2aa3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -16,8 +16,7 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C.Encoding; -import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -36,8 +35,8 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public boolean supportsOutput(Format format, @Encoding int encoding) { - return sink.supportsOutput(format, encoding); + public boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding) { + return sink.supportsOutput(channelCount, sampleRate, encoding); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index f953d38866..2e6dc79afb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -226,8 +226,10 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - && !audioSink.supportsOutput(format, format.pcmEncoding)) - || !audioSink.supportsOutput(format, C.ENCODING_PCM_16BIT)) { + && !audioSink.supportsOutput( + format.channelCount, format.sampleRate, format.pcmEncoding)) + || !audioSink.supportsOutput( + format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { // Assume the decoder outputs 16-bit PCM, unless the input is raw. return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); } @@ -461,8 +463,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { // E-AC3 JOC is object-based so the output channel count is arbitrary. - Format eAc3JocFormat = format.buildUpon().setChannelCount(Format.NO_VALUE).build(); - if (audioSink.supportsOutput(eAc3JocFormat, C.ENCODING_E_AC3_JOC)) { + if (audioSink.supportsOutput( + /* channelCount= */ Format.NO_VALUE, format.sampleRate, C.ENCODING_E_AC3_JOC)) { return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC, format.codecs); } // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. @@ -470,7 +472,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @C.Encoding int encoding = MimeTypes.getEncoding(mimeType, format.codecs); - if (audioSink.supportsOutput(format, encoding)) { + if (audioSink.supportsOutput(format.channelCount, format.sampleRate, encoding)) { return encoding; } else { return C.ENCODING_INVALID; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index 7102bcd5ea..e916ca549f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -21,7 +21,6 @@ import static org.robolectric.annotation.Config.TARGET_SDK; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; @@ -51,11 +50,6 @@ public final class DefaultAudioSinkTest { private static final int SAMPLE_RATE_44_1 = 44100; private static final int TRIM_100_MS_FRAME_COUNT = 4410; private static final int TRIM_10_MS_FRAME_COUNT = 441; - private static final Format STEREO_44_1_FORMAT = - new Format.Builder() - .setChannelCount(CHANNEL_COUNT_STEREO) - .setSampleRate(SAMPLE_RATE_44_1) - .build(); private DefaultAudioSink defaultAudioSink; private ArrayAudioBufferSink arrayAudioBufferSink; @@ -207,13 +201,19 @@ public final class DefaultAudioSinkTest { @Config(minSdk = OLDEST_SDK, maxSdk = 20) @Test public void doesNotSupportFloatOutputBeforeApi21() { - assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isFalse(); + assertThat( + defaultAudioSink.supportsOutput( + CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) + .isFalse(); } @Config(minSdk = 21, maxSdk = TARGET_SDK) @Test public void supportsFloatOutputFromApi21() { - assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isTrue(); + assertThat( + defaultAudioSink.supportsOutput( + CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) + .isTrue(); } @Test @@ -221,7 +221,10 @@ public final class DefaultAudioSinkTest { DefaultAudioSink defaultAudioSink = new DefaultAudioSink( new AudioCapabilities(new int[] {C.ENCODING_AAC_LC}, 2), new AudioProcessor[0]); - assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_AAC_LC)).isFalse(); + assertThat( + defaultAudioSink.supportsOutput( + CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_AAC_LC)) + .isFalse(); } private void configureDefaultAudioSink(int channelCount) throws AudioSink.ConfigurationException { From 5612ac50a332e425dc130c3c13a139b9e6fce9ec Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 11 Jun 2020 20:04:05 +0100 Subject: [PATCH 0467/1052] Rollback of https://github.com/google/ExoPlayer/commit/e1beb1d1946bb8ca94f62578aee8cbadd97b6e2b *** Original commit *** Expose experimental offload scheduling Add a new scheduling mode that stops ExoPlayer main loop when the audio offload buffer is full and resume it when it has been partially played. This mode needs to be enabled and dissabled manually by the app for now. #exo-offload *** PiperOrigin-RevId: 315948869 --- RELEASENOTES.md | 1 - .../exoplayer2/DefaultRenderersFactory.java | 17 ++---- .../google/android/exoplayer2/ExoPlayer.java | 37 ------------ .../android/exoplayer2/ExoPlayerImpl.java | 5 -- .../exoplayer2/ExoPlayerImplInternal.java | 47 +-------------- .../google/android/exoplayer2/Renderer.java | 32 ---------- .../android/exoplayer2/SimpleExoPlayer.java | 5 -- .../android/exoplayer2/audio/AudioSink.java | 11 ---- .../audio/AudioTrackPositionTracker.java | 5 -- .../exoplayer2/audio/DefaultAudioSink.java | 58 +------------------ .../audio/MediaCodecAudioRenderer.java | 19 ------ .../exoplayer2/testutil/StubExoPlayer.java | 5 -- 12 files changed, 6 insertions(+), 236 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c1795b6a12..03b92a3033 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -158,7 +158,6 @@ * No longer use a `MediaCodec` in audio passthrough mode. * Check `DefaultAudioSink` supports passthrough, in addition to checking the `AudioCapabilities` - * Add an experimental scheduling mode to save power in offload. ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Adjust input timestamps in `MediaCodecRenderer` to account for the Codec2 MP3 decoder having lower timestamps on the output side. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index 3913922c3c..bd56974b32 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -219,20 +219,12 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Sets whether audio should be played using the offload path. - * - *

        Audio offload disables ExoPlayer audio processing, but significantly reduces the energy - * consumption of the playback when {@link - * ExoPlayer#experimental_enableOffloadScheduling(boolean)} is enabled. - * - *

        Most Android devices can only support one offload {@link android.media.AudioTrack} at a time - * and can invalidate it at any time. Thus an app can never be guaranteed that it will be able to - * play in offload. + * Sets whether audio should be played using the offload path. Audio offload disables audio + * processors (for example speed adjustment). * *

        The default value is {@code false}. * - * @param enableOffload Whether to enable use of audio offload for supported formats, if - * available. + * @param enableOffload If audio offload should be used. * @return This factory, for convenience. */ public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { @@ -431,8 +423,7 @@ public class DefaultRenderersFactory implements RenderersFactory { * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param enableOffload Whether to enable use of audio offload for supported formats, if - * available. + * @param enableOffload If the renderer should use audio offload for all supported formats. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b3b369b68e..b4cd9a399d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -20,8 +20,6 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.analytics.AnalyticsCollector; -import com.google.android.exoplayer2.audio.AudioCapabilities; -import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; @@ -599,39 +597,4 @@ public interface ExoPlayer extends Player { * @see #setPauseAtEndOfMediaItems(boolean) */ boolean getPauseAtEndOfMediaItems(); - - /** - * Enables audio offload scheduling, which runs ExoPlayer's main loop as rarely as possible when - * playing an audio stream using audio offload. - * - *

        Only use this scheduling mode if the player is not displaying anything to the user. For - * example when the application is in the background, or the screen is off. The player state - * (including position) is rarely updated (between 10s and 1min). - * - *

        While offload scheduling is enabled, player events may be delivered severely delayed and - * apps should not interact with the player. When returning to the foreground, disable offload - * scheduling before interacting with the player - * - *

        This mode should save significant power when the phone is playing offload audio with the - * screen off. - * - *

        This mode only has an effect when playing an audio track in offload mode, which requires all - * the following: - * - *

          - *
        • audio offload rendering is enabled in {@link - * DefaultRenderersFactory#setEnableAudioOffload} or the equivalent option passed to {@link - * com.google.android.exoplayer2.audio.DefaultAudioSink#DefaultAudioSink(AudioCapabilities, - * DefaultAudioSink.AudioProcessorChain, boolean, boolean)}. - *
        • an audio track is playing in a format which the device supports offloading (for example - * MP3 or AAC). - *
        • The {@link com.google.android.exoplayer2.audio.AudioSink} is playing with an offload - * {@link android.media.AudioTrack}. - *
        - * - *

        This method is experimental, and will be renamed or removed in a future release. - * - * @param enableOffloadScheduling Whether to enable offload scheduling. - */ - void experimental_enableOffloadScheduling(boolean enableOffloadScheduling); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 51c8a9ea60..26357a18dc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -202,11 +202,6 @@ import java.util.concurrent.TimeoutException; internalPlayer.experimental_throwWhenStuckBuffering(); } - @Override - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - internalPlayer.experimental_enableOffloadScheduling(enableOffloadScheduling); - } - @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index c5e6b06c19..53c8a5d080 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -94,15 +94,6 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; - /** - * Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant - * power saving. - * - *

        This value is probably too high, power measurements are needed adjust it, but as renderer - * sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s, - * this does not matter for now. - */ - private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -136,8 +127,6 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; - private boolean requestForRendererSleep; - private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; @@ -210,13 +199,6 @@ import java.util.concurrent.atomic.AtomicBoolean; throwWhenStuckBuffering = true; } - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - offloadSchedulingEnabled = enableOffloadScheduling; - if (!enableOffloadScheduling) { - handler.sendEmptyMessage(MSG_DO_SOME_WORK); - } - } - public void prepare() { handler.obtainMessage(MSG_PREPARE).sendToTarget(); } @@ -903,13 +885,12 @@ import java.util.concurrent.atomic.AtomicBoolean; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); + scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } - requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -919,14 +900,6 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } - private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { - if (offloadSchedulingEnabled && requestForRendererSleep) { - return; - } - - scheduleNextWork(operationStartTimeMs, intervalMs); - } - private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -2095,24 +2068,6 @@ import java.util.concurrent.atomic.AtomicBoolean; joining, mayRenderStartOfStream, periodHolder.getRendererOffset()); - - renderer.handleMessage( - Renderer.MSG_SET_WAKEUP_LISTENER, - new Renderer.WakeupListener() { - @Override - public void onSleep(long wakeupDeadlineMs) { - // Do not sleep if the expected sleep time is not long enough to save significant power. - if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) { - requestForRendererSleep = true; - } - } - - @Override - public void onWakeup() { - handler.sendEmptyMessage(MSG_DO_SOME_WORK); - } - }); - mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index 8620c2d752..fa73f9257d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -46,30 +46,6 @@ import java.lang.annotation.RetentionPolicy; */ public interface Renderer extends PlayerMessage.Target { - /** - * Some renderers can signal when {@link #render(long, long)} should be called. - * - *

        That allows the player to sleep until the next wakeup, instead of calling {@link - * #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save - * power. - */ - interface WakeupListener { - - /** - * The renderer no longer needs to render until the next wakeup. - * - * @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be - * called. - */ - void onSleep(long wakeupDeadlineMs); - - /** - * The renderer needs to render some frames. The client should call {@link #render(long, long)} - * at its earliest convenience. - */ - void onWakeup(); - } - /** * The type of a message that can be passed to a video renderer via {@link * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or @@ -161,14 +137,6 @@ public interface Renderer extends PlayerMessage.Target { * representing the audio session ID that will be attached to the underlying audio track. */ int MSG_SET_AUDIO_SESSION_ID = 102; - /** - * A type of a message that can be passed to a {@link Renderer} via {@link - * ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another - * component. - * - *

        The message payload must be a {@link WakeupListener} instance. - */ - int MSG_SET_WAKEUP_LISTENER = 103; /** * Applications or extensions may define custom {@code MSG_*} constants that can be passed to * renderers. These custom constants must be greater than or equal to this value. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 4c36f9fc99..d1f0cfc798 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -633,11 +633,6 @@ public class SimpleExoPlayer extends BasePlayer C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); } - @Override - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - player.experimental_enableOffloadScheduling(enableOffloadScheduling); - } - @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 8bebd97a67..c4fa25d6bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -90,17 +90,6 @@ public interface AudioSink { * @param skipSilenceEnabled Whether skipping silences is enabled. */ void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled); - - /** Called when the offload buffer has been partially emptied. */ - default void onOffloadBufferEmptying() {} - - /** - * Called when the offload buffer has been filled completely. - * - * @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link - * #onOffloadBufferEmptying()} will be called. - */ - default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index ae2eb92044..d15fe44fc0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -335,11 +335,6 @@ import java.lang.reflect.Method; return bufferSize - bytesPending; } - /** Returns the duration of audio that is buffered but unplayed. */ - public long getPendingBufferDurationMs(long writtenFrames) { - return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition())); - } - /** Returns whether the track is in an invalid state and must be recreated. */ public boolean isStalled(long writtenFrames) { return forceResetWorkaroundTimeMs != C.TIME_UNSET diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 880aefdbbd..bc3c321cac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -20,7 +20,6 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; import android.os.ConditionVariable; -import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -36,7 +35,6 @@ import java.nio.ByteOrder; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; -import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -276,7 +274,6 @@ public final class DefaultAudioSink implements AudioSink { private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; private final boolean enableOffload; - @Nullable private final StreamEventCallback offloadStreamEventCallback; @Nullable private Listener listener; /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ @@ -369,10 +366,7 @@ public final class DefaultAudioSink implements AudioSink { * be available when float output is in use. * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played * with offload and encoded audio passthrough, it will be played in offload. Audio offload is - * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android - * devices can only support one offload {@link android.media.AudioTrack} at a time and can - * invalidate it at any time. Thus an app can never be guaranteed that it will be able to play - * in offload. + * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, @@ -410,7 +404,6 @@ public final class DefaultAudioSink implements AudioSink { activeAudioProcessors = new AudioProcessor[0]; outputBuffers = new ByteBuffer[0]; mediaPositionParametersCheckpoints = new ArrayDeque<>(); - offloadStreamEventCallback = Util.SDK_INT >= 29 ? new StreamEventCallback() : null; } // AudioSink implementation. @@ -570,10 +563,6 @@ public final class DefaultAudioSink implements AudioSink { audioTrack = Assertions.checkNotNull(configuration) .buildAudioTrack(tunneling, audioAttributes, audioSessionId); - if (isOffloadedPlayback(audioTrack)) { - // Receive stream event callbacks on the current (playback) thread. - Assertions.checkNotNull(offloadStreamEventCallback).register(audioTrack); - } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { @@ -833,15 +822,6 @@ public final class DefaultAudioSink implements AudioSink { throw new WriteException(bytesWritten); } - if (isOffloadedPlayback(audioTrack) - && playing - && listener != null - && bytesWritten < bytesRemaining) { - long pendingDurationMs = - audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); - listener.onOffloadBufferFull(pendingDurationMs); - } - if (configuration.isInputPcm) { writtenPcmBytes += bytesWritten; } @@ -1060,9 +1040,6 @@ public final class DefaultAudioSink implements AudioSink { if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } - if (isOffloadedPlayback(audioTrack)) { - offloadStreamEventCallback.unregister(audioTrack); - } // AudioTrack.release can take some time, so we call it on a background thread. final AudioTrack toRelease = audioTrack; audioTrack = null; @@ -1252,39 +1229,6 @@ public final class DefaultAudioSink implements AudioSink { audioFormat, audioAttributes.getAudioAttributesV21()); } - @EnsuresNonNullIf( - result = true, - expression = {"offloadStreamEventCallback"}) - private static boolean isOffloadedPlayback(AudioTrack audioTrack) { - return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); - } - - @RequiresApi(29) - private final class StreamEventCallback extends AudioTrack.StreamEventCallback { - private final Handler handler; - - public StreamEventCallback() { - handler = new Handler(); - } - - @Override - public void onDataRequest(AudioTrack track, int size) { - Assertions.checkState(track == DefaultAudioSink.this.audioTrack); - if (listener != null) { - listener.onOffloadBufferEmptying(); - } - } - - public void register(AudioTrack audioTrack) { - audioTrack.registerStreamEventCallback(handler::post, this); - } - - public void unregister(AudioTrack audioTrack) { - audioTrack.unregisterStreamEventCallback(this); - handler.removeCallbacksAndMessages(/* token= */ null); - } - } - private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 2e6dc79afb..c98bd9bbb9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -91,8 +91,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean allowFirstBufferPositionDiscontinuity; private boolean allowPositionDiscontinuity; - @Nullable private WakeupListener wakeupListener; - /** * @param context A context. * @param mediaCodecSelector A decoder selector. @@ -697,9 +695,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case MSG_SET_AUDIO_SESSION_ID: audioSink.setAudioSessionId((Integer) message); break; - case MSG_SET_WAKEUP_LISTENER: - this.wakeupListener = (WakeupListener) message; - break; default: super.handleMessage(messageType, message); break; @@ -879,19 +874,5 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } - - @Override - public void onOffloadBufferEmptying() { - if (wakeupListener != null) { - wakeupListener.onWakeup(); - } - } - - @Override - public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { - if (wakeupListener != null) { - wakeupListener.onSleep(bufferEmptyingDeadlineMs); - } - } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index c79a128f81..b4678cb7cf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -465,9 +465,4 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { public boolean getPauseAtEndOfMediaItems() { throw new UnsupportedOperationException(); } - - @Override - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - throw new UnsupportedOperationException(); - } } From 28a7e59d7bb45ca9252c585bd239790881a90d08 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 10:15:01 +0100 Subject: [PATCH 0468/1052] Fix javadoc PiperOrigin-RevId: 316071392 --- .../exoplayer2/source/dash/manifest/DashManifestParser.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 23f264e64b..3a51d34e20 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -841,9 +841,8 @@ public class DashManifestParser extends DefaultHandler } /** - * /** * Parses a single EventStream node in the manifest. - *

        + * * @param xpp The current xml parser. * @return The {@link EventStream} parsed from this EventStream node. * @throws XmlPullParserException If there is any error parsing this node. From c5144dc777c816219fb80f495038e57300291dfa Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 11:37:50 +0100 Subject: [PATCH 0469/1052] Fix catch type in ImaAdsLoader defensive checks PiperOrigin-RevId: 316079131 --- .../android/exoplayer2/ext/ima/ImaAdsLoader.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index ad6a34e84f..4cf712f97e 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -715,7 +715,7 @@ public final class ImaAdsLoader } try { handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("handlePrepareError", e); } } @@ -742,7 +742,7 @@ public final class ImaAdsLoader adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); hasAdPlaybackState = true; updateAdPlaybackState(); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("onAdsManagerLoaded", e); } } @@ -762,7 +762,7 @@ public final class ImaAdsLoader } try { handleAdEvent(adEvent); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("onAdEvent", e); } } @@ -784,7 +784,7 @@ public final class ImaAdsLoader } else if (isAdGroupLoadError(error)) { try { handleAdGroupLoadError(error); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("onAdError", e); } } @@ -885,7 +885,7 @@ public final class ImaAdsLoader adPlaybackState = adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); updateAdPlaybackState(); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("loadAd", e); } } @@ -959,7 +959,7 @@ public final class ImaAdsLoader Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { stopAdInternal(); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("stopAd", e); } } From 5a88e0bc1d37b8cd16f993ae51ebe17fa9358ab9 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 12:20:43 +0100 Subject: [PATCH 0470/1052] Handle errors in all VideoAdPlayer callbacks Some but not all VideoAdPlayer callbacks from the IMA SDK included defensive handling of unexpected cases. Add the remaining ones. Issue: #7492 PiperOrigin-RevId: 316082651 --- RELEASENOTES.md | 5 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 65 +++++++++++-------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 46cad81547..acc6338b44 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -208,7 +208,10 @@ ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue manipulation API. -* IMA extension: Add option to skip ads before the start position. +* IMA extension: + * Add option to skip ads before the start position. + * Catch unexpected errors in `stopAd` to avoid a crash + ([#7492](https://github.com/google/ExoPlayer/issues/7492)). * Demo app: Retain previous position in list of samples. * Add Guava dependency. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 4cf712f97e..51e6ed6f8f 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -916,32 +916,36 @@ public final class ImaAdsLoader Log.w(TAG, "Unexpected playAd without stopAd"); } - if (imaAdState == IMA_AD_STATE_NONE) { - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - imaAdMediaInfo = adMediaInfo; - imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(adMediaInfo); - } - if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { - pendingAdPrepareErrorAdInfo = null; + try { + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(adMediaInfo); + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); } } - updateAdProgress(); - } else { - imaAdState = IMA_AD_STATE_PLAYING; - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(adMediaInfo); + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { + Assertions.checkNotNull(adsManager).pause(); } - } - if (!Assertions.checkNotNull(player).getPlayWhenReady()) { - Assertions.checkNotNull(adsManager).pause(); + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); } } @@ -955,9 +959,9 @@ public final class ImaAdsLoader return; } - Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); stopAdInternal(); } catch (RuntimeException e) { maybeNotifyInternalError("stopAd", e); @@ -973,10 +977,15 @@ public final class ImaAdsLoader // This method is called after content is resumed. return; } - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); + + try { + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); } } From f1b94f6f90609fb7b19157826ea96f3fdbf4e9ce Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 15:45:10 +0100 Subject: [PATCH 0471/1052] Add AdPlaybackState toString This is useful for debugging both in tests and via logging. PiperOrigin-RevId: 316102968 --- .../source/ads/AdPlaybackState.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 783a452b1a..d56c4eefac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -489,6 +489,54 @@ public final class AdPlaybackState { return result; } + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("AdPlaybackState(adResumePositionUs="); + sb.append(adResumePositionUs); + sb.append(", adGroups=["); + for (int i = 0; i < adGroups.length; i++) { + sb.append("adGroup(timeUs="); + sb.append(adGroupTimesUs[i]); + sb.append(", ads=["); + for (int j = 0; j < adGroups[i].states.length; j++) { + sb.append("ad(state="); + switch (adGroups[i].states[j]) { + case AD_STATE_UNAVAILABLE: + sb.append('_'); + break; + case AD_STATE_ERROR: + sb.append('!'); + break; + case AD_STATE_AVAILABLE: + sb.append('R'); + break; + case AD_STATE_PLAYED: + sb.append('P'); + break; + case AD_STATE_SKIPPED: + sb.append('S'); + break; + default: + sb.append('?'); + break; + } + sb.append(", durationUs="); + sb.append(adGroups[i].durationsUs[j]); + sb.append(')'); + if (j < adGroups[i].states.length - 1) { + sb.append(", "); + } + } + sb.append("])"); + if (i < adGroups.length - 1) { + sb.append(", "); + } + } + sb.append("])"); + return sb.toString(); + } + private boolean isPositionBeforeAdGroup( long positionUs, long periodDurationUs, int adGroupIndex) { if (positionUs == C.TIME_END_OF_SOURCE) { From 0a617146b054ca546ce2bacbc4b0de125e58458a Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 12 Jun 2020 17:37:57 +0100 Subject: [PATCH 0472/1052] Remove duplicate DECODE_ONLY check PiperOrigin-RevId: 316119460 --- .../exoplayer2/text/cea/CeaDecoder.java | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java index f42b2a99cf..81ef58a712 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java @@ -81,8 +81,7 @@ import java.util.PriorityQueue; Assertions.checkArgument(inputBuffer == dequeuedInputBuffer); CeaInputBuffer ceaInputBuffer = (CeaInputBuffer) inputBuffer; if (ceaInputBuffer.isDecodeOnly()) { - // We can drop this buffer early (i.e. before it would be decoded) as the CEA formats allow - // for decoding to begin mid-stream. + // We can start decoding anywhere in CEA formats, so discarding on the input side is fine. releaseInputBuffer(ceaInputBuffer); } else { ceaInputBuffer.queuedInputBufferCount = queuedInputBufferCount++; @@ -97,15 +96,12 @@ import java.util.PriorityQueue; if (availableOutputBuffers.isEmpty()) { return null; } - // iterate through all available input buffers whose timestamps are less than or equal - // to the current playback position; processing input buffers for future content should - // be deferred until they would be applicable + // Process input buffers up to the current playback position. Processing of input buffers for + // future content is deferred. while (!queuedInputBuffers.isEmpty() && Util.castNonNull(queuedInputBuffers.peek()).timeUs <= playbackPositionUs) { CeaInputBuffer inputBuffer = Util.castNonNull(queuedInputBuffers.poll()); - // If the input buffer indicates we've reached the end of the stream, we can - // return immediately with an output buffer propagating that if (inputBuffer.isEndOfStream()) { // availableOutputBuffers.isEmpty() is checked at the top of the method, so this is safe. SubtitleOutputBuffer outputBuffer = Util.castNonNull(availableOutputBuffers.pollFirst()); @@ -116,18 +112,13 @@ import java.util.PriorityQueue; decode(inputBuffer); - // check if we have any caption updates to report if (isNewSubtitleDataAvailable()) { - // Even if the subtitle is decode-only; we need to generate it to consume the data so it - // isn't accidentally prepended to the next subtitle Subtitle subtitle = createSubtitle(); - if (!inputBuffer.isDecodeOnly()) { - // availableOutputBuffers.isEmpty() is checked at the top of the method, so this is safe. - SubtitleOutputBuffer outputBuffer = Util.castNonNull(availableOutputBuffers.pollFirst()); - outputBuffer.setContent(inputBuffer.timeUs, subtitle, Format.OFFSET_SAMPLE_RELATIVE); - releaseInputBuffer(inputBuffer); - return outputBuffer; - } + // availableOutputBuffers.isEmpty() is checked at the top of the method, so this is safe. + SubtitleOutputBuffer outputBuffer = Util.castNonNull(availableOutputBuffers.pollFirst()); + outputBuffer.setContent(inputBuffer.timeUs, subtitle, Format.OFFSET_SAMPLE_RELATIVE); + releaseInputBuffer(inputBuffer); + return outputBuffer; } releaseInputBuffer(inputBuffer); @@ -160,7 +151,7 @@ import java.util.PriorityQueue; @Override public void release() { - // Do nothing + // Do nothing. } /** From 41d4a132c4a4babfeeda34fcce9b6a5bb9c8a8ad Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 12 Jun 2020 18:21:56 +0100 Subject: [PATCH 0473/1052] Add configure() in MediaCodecAdapter The correct order of initializing the MediaCodec should be (as per documentation https://developer.android.com/reference/android/media/MediaCodec#initialization) "create -> setCallback -> configure -> start" but the MediaCodecRenderer currently does "create -> configure -> setCallback -> start" MediaCodec implementations did not complain about this so far, but the wrong sequence does not work with the MediaCodec in block mode (new mode in Android R) and also the ShadowMediaCodec won't operate in asynchronous mode otherwise. To initialize the MediaCodec in the correct order, this commit adds configure() in the MediaCodecAdapter so the MediaCodecRenderer can do: adapter.configure(); // sets the callback and then configures the codec adapter.start(); // starts the codec PiperOrigin-RevId: 316127680 --- .../audio/MediaCodecAudioRenderer.java | 5 +- .../AsynchronousMediaCodecAdapter.java | 198 ++++++---- .../mediacodec/MediaCodecAdapter.java | 24 +- .../mediacodec/MediaCodecRenderer.java | 10 +- .../SynchronousMediaCodecAdapter.java | 17 + .../video/MediaCodecVideoRenderer.java | 7 +- .../AsynchronousMediaCodecAdapterTest.java | 353 +++++++----------- .../gts/DebugRenderersFactory.java | 5 +- 8 files changed, 324 insertions(+), 295 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index c98bd9bbb9..a4816c5372 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -32,6 +32,7 @@ import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener.EventDispatcher; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; @@ -289,7 +290,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { @@ -301,7 +302,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media && !MimeTypes.AUDIO_RAW.equals(format.sampleMimeType); MediaFormat mediaFormat = getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate); - codec.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); + codecAdapter.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); // Store the input MIME type if we're using the passthrough codec. passthroughFormat = passthroughEnabled ? format : null; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java index 35d3989c29..f7c89b89c7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java @@ -17,9 +17,13 @@ package com.google.android.exoplayer2.mediacodec; import android.media.MediaCodec; +import android.media.MediaCrypto; import android.media.MediaFormat; import android.os.Handler; import android.os.HandlerThread; +import android.os.Looper; +import android.view.Surface; +import androidx.annotation.GuardedBy; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -45,22 +49,32 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_CREATED, STATE_STARTED, STATE_SHUT_DOWN}) + @IntDef({STATE_CREATED, STATE_CONFIGURED, STATE_STARTED, STATE_SHUT_DOWN}) private @interface State {} private static final int STATE_CREATED = 0; - private static final int STATE_STARTED = 1; - private static final int STATE_SHUT_DOWN = 2; + private static final int STATE_CONFIGURED = 1; + private static final int STATE_STARTED = 2; + private static final int STATE_SHUT_DOWN = 3; + private final Object lock; + + @GuardedBy("lock") private final MediaCodecAsyncCallback mediaCodecAsyncCallback; + private final MediaCodec codec; private final HandlerThread handlerThread; private @MonotonicNonNull Handler handler; + + @GuardedBy("lock") private long pendingFlushCount; + private @State int state; - private Runnable codecStartRunnable; private final MediaCodecInputBufferEnqueuer bufferEnqueuer; - @Nullable private IllegalStateException internalException; + + @GuardedBy("lock") + @Nullable + private IllegalStateException internalException; /** * Creates an instance that wraps the specified {@link MediaCodec}. Instances created with this @@ -101,121 +115,164 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; boolean enableAsynchronousQueueing, int trackType, HandlerThread handlerThread) { - mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); + this.lock = new Object(); + this.mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); this.codec = codec; this.handlerThread = handlerThread; - state = STATE_CREATED; - codecStartRunnable = codec::start; - if (enableAsynchronousQueueing) { - bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); - } else { - bufferEnqueuer = new SynchronousMediaCodecBufferEnqueuer(this.codec); - } + this.bufferEnqueuer = + enableAsynchronousQueueing + ? new AsynchronousMediaCodecBufferEnqueuer(codec, trackType) + : new SynchronousMediaCodecBufferEnqueuer(this.codec); + this.state = STATE_CREATED; } @Override - public synchronized void start() { + public void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags) { handlerThread.start(); handler = new Handler(handlerThread.getLooper()); codec.setCallback(this, handler); + codec.configure(mediaFormat, surface, crypto, flags); + state = STATE_CONFIGURED; + } + + @Override + public void start() { bufferEnqueuer.start(); - codecStartRunnable.run(); + codec.start(); state = STATE_STARTED; } @Override public void queueInputBuffer( int index, int offset, int size, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it does not interact with the - // mediaCodecAsyncCallback. bufferEnqueuer.queueInputBuffer(index, offset, size, presentationTimeUs, flags); } @Override public void queueSecureInputBuffer( int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it does not interact with the - // mediaCodecAsyncCallback. bufferEnqueuer.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); } @Override - public synchronized int dequeueInputBufferIndex() { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueInputBufferIndex(); + public int dequeueInputBufferIndex() { + synchronized (lock) { + if (isFlushing()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return mediaCodecAsyncCallback.dequeueInputBufferIndex(); + } } } @Override - public synchronized int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); + public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { + synchronized (lock) { + if (isFlushing()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); + } } } @Override - public synchronized MediaFormat getOutputFormat() { - return mediaCodecAsyncCallback.getOutputFormat(); - } - - @Override - public synchronized void flush() { - bufferEnqueuer.flush(); - codec.flush(); - ++pendingFlushCount; - Util.castNonNull(handler).post(this::onFlushCompleted); - } - - @Override - public synchronized void shutdown() { - if (state == STATE_STARTED) { - bufferEnqueuer.shutdown(); - handlerThread.quit(); - mediaCodecAsyncCallback.flush(); + public MediaFormat getOutputFormat() { + synchronized (lock) { + return mediaCodecAsyncCallback.getOutputFormat(); } - state = STATE_SHUT_DOWN; } @Override - public synchronized void onInputBufferAvailable(MediaCodec codec, int index) { - mediaCodecAsyncCallback.onInputBufferAvailable(codec, index); + public void flush() { + synchronized (lock) { + bufferEnqueuer.flush(); + codec.flush(); + ++pendingFlushCount; + Util.castNonNull(handler).post(this::onFlushCompleted); + } } @Override - public synchronized void onOutputBufferAvailable( - MediaCodec codec, int index, MediaCodec.BufferInfo info) { - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, index, info); + public void shutdown() { + synchronized (lock) { + if (state == STATE_STARTED) { + bufferEnqueuer.shutdown(); + } + if (state == STATE_CONFIGURED || state == STATE_STARTED) { + handlerThread.quit(); + mediaCodecAsyncCallback.flush(); + // Leave the adapter in a flushing state so that + // it will not dequeue anything. + ++pendingFlushCount; + } + state = STATE_SHUT_DOWN; + } } @Override - public synchronized void onError(MediaCodec codec, MediaCodec.CodecException e) { - mediaCodecAsyncCallback.onError(codec, e); + public MediaCodec getCodec() { + return codec; + } + + // Called from the handler thread. + + @Override + public void onInputBufferAvailable(MediaCodec codec, int index) { + synchronized (lock) { + mediaCodecAsyncCallback.onInputBufferAvailable(codec, index); + } } @Override - public synchronized void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { - mediaCodecAsyncCallback.onOutputFormatChanged(codec, format); + public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) { + synchronized (lock) { + mediaCodecAsyncCallback.onOutputBufferAvailable(codec, index, info); + } + } + + @Override + public void onError(MediaCodec codec, MediaCodec.CodecException e) { + synchronized (lock) { + mediaCodecAsyncCallback.onError(codec, e); + } + } + + @Override + public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { + synchronized (lock) { + mediaCodecAsyncCallback.onOutputFormatChanged(codec, format); + } } @VisibleForTesting /* package */ void onMediaCodecError(IllegalStateException e) { - mediaCodecAsyncCallback.onMediaCodecError(e); + synchronized (lock) { + mediaCodecAsyncCallback.onMediaCodecError(e); + } } @VisibleForTesting - /* package */ void setCodecStartRunnable(Runnable codecStartRunnable) { - this.codecStartRunnable = codecStartRunnable; + @Nullable + /* package */ Looper getLooper() { + return handlerThread.getLooper(); } - private synchronized void onFlushCompleted() { - if (state != STATE_STARTED) { - // The adapter has been shutdown. + private void onFlushCompleted() { + synchronized (lock) { + onFlushCompletedSynchronized(); + } + } + + @GuardedBy("lock") + private void onFlushCompletedSynchronized() { + if (state == STATE_SHUT_DOWN) { return; } @@ -231,7 +288,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; mediaCodecAsyncCallback.flush(); try { - codecStartRunnable.run(); + codec.start(); } catch (IllegalStateException e) { internalException = e; } catch (Exception e) { @@ -239,16 +296,19 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } } - private synchronized boolean isFlushing() { + @GuardedBy("lock") + private boolean isFlushing() { return pendingFlushCount > 0; } - private synchronized void maybeThrowException() { + @GuardedBy("lock") + private void maybeThrowException() { maybeThrowInternalException(); mediaCodecAsyncCallback.maybeThrowMediaCodecException(); } - private synchronized void maybeThrowInternalException() { + @GuardedBy("lock") + private void maybeThrowInternalException() { if (internalException != null) { IllegalStateException e = internalException; internalException = null; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java index 1be850c899..a413790847 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java @@ -17,7 +17,10 @@ package com.google.android.exoplayer2.mediacodec; import android.media.MediaCodec; +import android.media.MediaCrypto; import android.media.MediaFormat; +import android.view.Surface; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.CryptoInfo; /** @@ -30,12 +33,24 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; * * @see com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.MediaCodecOperationMode */ -/* package */ interface MediaCodecAdapter { +public interface MediaCodecAdapter { /** - * Starts this instance. + * Configures this adapter and the underlying {@link MediaCodec}. Needs to be called before {@link + * #start()}. * - * @see MediaCodec#start(). + * @see MediaCodec#configure(MediaFormat, Surface, MediaCrypto, int) + */ + void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags); + + /** + * Starts this instance. Needs to be called after {@link #configure}. + * + * @see MediaCodec#start() */ void start(); @@ -109,4 +124,7 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; * is a risk the adapter might interact with a stopped or released {@link MediaCodec}. */ void shutdown(); + + /** Returns the {@link MediaCodec} instance of this adapter. */ + MediaCodec getCodec(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 21cc04ec23..6f35f21583 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -532,7 +532,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * Configures a newly created {@link MediaCodec}. * * @param codecInfo Information about the {@link MediaCodec} being configured. - * @param codec The {@link MediaCodec} to configure. + * @param codecAdapter The {@link MediaCodecAdapter} to configure. * @param format The {@link Format} for which the codec is being configured. * @param crypto For drm protected playbacks, a {@link MediaCrypto} to use for decryption. * @param codecOperatingRate The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if @@ -540,7 +540,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { */ protected abstract void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate); @@ -1036,8 +1036,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { /** * Configures passthrough where no codec is used. Called instead of {@link - * #configureCodec(MediaCodecInfo, MediaCodec, Format, MediaCrypto, float)} when no codec is used - * in passthrough. + * #configureCodec(MediaCodecInfo, MediaCodecAdapter, Format, MediaCrypto, float)} when no codec + * is used in passthrough. */ private void initPassthrough(Format format) { disablePassthrough(); // In case of transition between 2 passthrough formats. @@ -1088,7 +1088,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { TraceUtil.endSection(); TraceUtil.beginSection("configureCodec"); - configureCodec(codecInfo, codec, inputFormat, crypto, codecOperatingRate); + configureCodec(codecInfo, codecAdapter, inputFormat, crypto, codecOperatingRate); TraceUtil.endSection(); TraceUtil.beginSection("startCodec"); codecAdapter.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java index f50b49e602..f5138e90f0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java @@ -17,7 +17,10 @@ package com.google.android.exoplayer2.mediacodec; import android.media.MediaCodec; +import android.media.MediaCrypto; import android.media.MediaFormat; +import android.view.Surface; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.CryptoInfo; /** @@ -31,6 +34,15 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; this.codec = mediaCodec; } + @Override + public void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags) { + codec.configure(mediaFormat, surface, crypto, flags); + } + @Override public void start() { codec.start(); @@ -71,4 +83,9 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; @Override public void shutdown() {} + + @Override + public MediaCodec getCodec() { + return codec; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 60e376cb76..aefd52ab11 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -42,6 +42,7 @@ import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; @@ -552,7 +553,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { @@ -575,9 +576,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } surface = dummySurface; } - codec.configure(mediaFormat, surface, crypto, 0); + codecAdapter.configure(mediaFormat, surface, crypto, 0); if (Util.SDK_INT >= 23 && tunneling) { - tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codecAdapter.getCodec()); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java index ee6f8690e2..9a3596d518 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java @@ -16,32 +16,25 @@ package com.google.android.exoplayer2.mediacodec; -import static com.google.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; +import static org.robolectric.annotation.LooperMode.Mode.PAUSED; import android.media.MediaCodec; import android.media.MediaFormat; -import android.os.Handler; import android.os.HandlerThread; -import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.Shadows; import org.robolectric.annotation.LooperMode; -import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link AsynchronousMediaCodecAdapter}. */ -@LooperMode(LEGACY) +@LooperMode(PAUSED) @RunWith(AndroidJUnit4.class) public class AsynchronousMediaCodecAdapterTest { private AsynchronousMediaCodecAdapter adapter; @@ -59,7 +52,6 @@ public class AsynchronousMediaCodecAdapterTest { /* enableAsynchronousQueueing= */ false, /* trackType= */ C.TRACK_TYPE_VIDEO, handlerThread); - adapter.setCodecStartRunnable(() -> {}); bufferInfo = new MediaCodec.BufferInfo(); } @@ -67,51 +59,46 @@ public class AsynchronousMediaCodecAdapterTest { public void tearDown() { adapter.shutdown(); - assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); - } - - @Test - public void startAndShutdown_works() { - adapter.start(); - adapter.shutdown(); - } - - @Test - public void dequeueInputBufferIndex_withAfterFlushFailed_throwsException() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new IllegalStateException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); + assertThat(handlerThread.hasQuit()).isTrue(); } @Test public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); + // After start(), the ShadowMediaCodec offers one input buffer, which is available only if we + // progress the adapter's looper. We don't progress the looper so that the buffer is not + // available. + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + adapter.start(); - adapter.onInputBufferAvailable(codec, 0); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @Test public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.onInputBufferAvailable(codec, 0); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); + + // Flush enqueues a task in the looper, but we won't progress the looper to leave flush() + // in a pending state. adapter.flush(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); @@ -119,153 +106,137 @@ public class AsynchronousMediaCodecAdapterTest { @Test public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - handler.post(() -> adapter.onInputBufferAvailable(codec, bufferIndex)); - } - adapter.flush(); // Enqueues a flush event after the onInputBufferAvailable callbacks - // Enqueue another onInputBufferAvailable after the flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 10)); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(10); + adapter.flush(); + // Progress the looper to complete flush(): the adapter should call codec.start(), triggering + // the ShadowMediaCodec to offer input buffer 0. + shadowOf(adapter.getLooper()).idle(); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @Test public void dequeueInputBufferIndex_withMediaCodecError_throwsException() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); + + // Set an error directly on the adapter (not through the looper). adapter.onMediaCodecError(new IllegalStateException("error from codec")); assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); } @Test - public void dequeueOutputBufferIndex_withInternalException_throwsException() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); + public void dequeueInputBufferIndex_afterShutdown_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.flush(); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); + adapter.shutdown(); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test - public void dequeueOutputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); + public void dequeueOutputBufferIndex_withoutOutputBuffer_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + adapter.start(); + // After start(), the ShadowMediaCodec offers an output format change. + shadowOf(adapter.getLooper()).idle(); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // Assert that output buffer is available. assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - MediaCodec.BufferInfo enqueuedBufferInfo = new MediaCodec.BufferInfo(); - adapter.onOutputBufferAvailable(codec, 0, enqueuedBufferInfo); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); - assertThat(adapter.dequeueOutputBufferIndex((bufferInfo))).isEqualTo(0); - assertBufferInfosEqual(enqueuedBufferInfo, bufferInfo); + int index = adapter.dequeueInputBufferIndex(); + adapter.queueInputBuffer(index, 0, 0, 0, 0); + // Progress the looper so that the ShadowMediaCodec processes the input buffer. + shadowOf(adapter.getLooper()).idle(); + + // The ShadowMediaCodec will first offer an output format and then the output buffer. + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // Assert it's the ShadowMediaCodec's output format + assertThat(adapter.getOutputFormat().getByteBuffer("csd-0")).isNotNull(); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(index); } @Test public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.dequeueOutputBufferIndex(bufferInfo); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); + + // Flush enqueues a task in the looper, but we won't progress the looper to leave flush() + // in a pending state. adapter.flush(); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } - @Test - public void dequeueOutputBufferIndex_withFlushCompletedAndOutputBuffer_returnsOutputBuffer() { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - outBufferInfo.presentationTimeUs = i; - handler.post(() -> adapter.onOutputBufferAvailable(codec, bufferIndex, outBufferInfo)); - } - adapter.flush(); // Enqueues a flush event after the onOutputBufferAvailable callbacks - // Enqueue another onOutputBufferAvailable after the flush event - MediaCodec.BufferInfo lastBufferInfo = new MediaCodec.BufferInfo(); - lastBufferInfo.presentationTimeUs = 10; - handler.post(() -> adapter.onOutputBufferAvailable(codec, 10, lastBufferInfo)); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(10); - assertBufferInfosEqual(lastBufferInfo, bufferInfo); - } - @Test public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); + + // Set an error directly on the adapter. adapter.onMediaCodecError(new IllegalStateException("error from codec")); assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); } @Test - public void dequeueOutputBufferIndex_withPendingOutputFormat_returnsPendingOutputFormat() { + public void dequeueOutputBufferIndex_afterShutdown_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - MediaCodec.BufferInfo outputBufferInfo = new MediaCodec.BufferInfo(); - MediaFormat pendingMediaFormat = new MediaFormat(); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); - adapter.onOutputFormatChanged(codec, new MediaFormat()); - adapter.onOutputBufferAvailable(codec, /* index= */ 0, new MediaCodec.BufferInfo()); - adapter.onOutputFormatChanged(codec, pendingMediaFormat); - adapter.onOutputBufferAvailable(codec, /* index= */ 1, new MediaCodec.BufferInfo()); - // Flush should clear the output queue except from the last pending output format received. - adapter.flush(); - shadowOf(handlerThread.getLooper()).idle(); - adapter.onOutputBufferAvailable(codec, /* index= */ 2, new MediaCodec.BufferInfo()); + int index = adapter.dequeueInputBufferIndex(); + adapter.queueInputBuffer(index, 0, 0, 0, 0); + // Progress the looper so that the ShadowMediaCodec processes the input buffer. + shadowOf(adapter.getLooper()).idle(); + adapter.shutdown(); - assertThat(adapter.dequeueOutputBufferIndex(outputBufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(pendingMediaFormat); - assertThat(adapter.dequeueOutputBufferIndex(outputBufferInfo)).isEqualTo(2); - } - - @Test - public void dequeueOutputBufferIndex_withPendingAndNewOutputFormat_returnsNewOutputFormat() { - adapter.start(); - MediaCodec.BufferInfo outputBufferInfo = new MediaCodec.BufferInfo(); - MediaFormat pendingMediaFormat = new MediaFormat(); - MediaFormat latestOutputFormat = new MediaFormat(); - - adapter.onOutputFormatChanged(codec, new MediaFormat()); - adapter.onOutputBufferAvailable(codec, /* index= */ 0, new MediaCodec.BufferInfo()); - adapter.onOutputFormatChanged(codec, pendingMediaFormat); - adapter.onOutputBufferAvailable(codec, /* index= */ 1, new MediaCodec.BufferInfo()); - // Flush should clear the output queue except from the last pending output format received. - adapter.flush(); - shadowOf(handlerThread.getLooper()).idle(); - // New output format should overwrite the pending format. - adapter.onOutputFormatChanged(codec, latestOutputFormat); - - assertThat(adapter.dequeueOutputBufferIndex(outputBufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(latestOutputFormat); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void getOutputFormat_withoutFormatReceived_throwsException() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); @@ -273,107 +244,67 @@ public class AsynchronousMediaCodecAdapterTest { @Test public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - MediaFormat[] formats = new MediaFormat[10]; - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - adapter.onOutputFormatChanged(codec, formats[i]); - } + // After start(), the ShadowMediaCodec offers an output format, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); - for (int i = 0; i < 10; i++) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - // A subsequent call to getOutputFormat() should return the previously fetched format - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - } + // Add another format directly on the adapter. + adapter.onOutputFormatChanged(codec, createMediaFormat("format2")); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // The first format is the ShadowMediaCodec's output format. + assertThat(adapter.getOutputFormat().getByteBuffer("csd-0")).isNotNull(); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // The 2nd format is the format we enqueued 'manually' above. + assertThat(adapter.getOutputFormat().getString("name")).isEqualTo("format2"); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void getOutputFormat_afterFlush_returnsPreviousFormat() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + adapter.start(); + // After start(), the ShadowMediaCodec offers an output format, which is available only if we + // progress the adapter's looper. + shadowOf(adapter.getLooper()).idle(); + + adapter.dequeueOutputBufferIndex(bufferInfo); + MediaFormat outputFormat = adapter.getOutputFormat(); + // Flush the adapter and progress the looper so that flush is completed. + adapter.flush(); + shadowOf(adapter.getLooper()).idle(); + + assertThat(adapter.getOutputFormat()).isEqualTo(outputFormat); + } + + private static MediaFormat createMediaFormat(String name) { MediaFormat format = new MediaFormat(); - adapter.start(); - adapter.onOutputFormatChanged(codec, format); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - - adapter.flush(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - - @Test - public void flush_multipleTimes_onlyLastFlushExecutes() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> codecStartCalls.incrementAndGet()); - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - handler.post(() -> adapter.onInputBufferAvailable(codec, 0)); - adapter.flush(); // Enqueues a flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 2)); - AtomicInteger milestoneCount = new AtomicInteger(0); - handler.post(() -> milestoneCount.incrementAndGet()); - adapter.flush(); // Enqueues a second flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 3)); - - // Progress the looper until the milestoneCount is increased. - // adapter.start() will call codec.start(). First flush event should not call codec.start(). - ShadowLooper shadowLooper = shadowOf(looper); - while (milestoneCount.get() < 1) { - shadowLooper.runOneTask(); - } - assertThat(codecStartCalls.get()).isEqualTo(1); - - // Wait until all tasks have been handled. - shadowLooper.idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(3); - assertThat(codecStartCalls.get()).isEqualTo(2); - } - - @Test - public void flush_andImmediatelyShutdown_flushIsNoOp() { - AtomicInteger onCodecStartCount = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> onCodecStartCount.incrementAndGet()); - adapter.start(); - // Grab reference to Looper before shutting down the adapter otherwise handlerThread.getLooper() - // might return null. - Looper looper = handlerThread.getLooper(); - adapter.flush(); - adapter.shutdown(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - // Only adapter.start() calls onCodecStart. - assertThat(onCodecStartCount.get()).isEqualTo(1); + format.setString("name", name); + return format; } private static class TestHandlerThread extends HandlerThread { - private static final AtomicLong INSTANCES_STARTED = new AtomicLong(0); + private boolean quit; - public TestHandlerThread(String name) { - super(name); + TestHandlerThread(String label) { + super(label); } - @Override - public synchronized void start() { - super.start(); - INSTANCES_STARTED.incrementAndGet(); + public boolean hasQuit() { + return quit; } @Override public boolean quit() { - boolean quit = super.quit(); - if (quit) { - INSTANCES_STARTED.decrementAndGet(); - } - return quit; + quit = true; + return super.quit(); } } } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java index 04b15f5240..28a0c05440 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; @@ -107,7 +108,7 @@ import java.util.ArrayList; @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, MediaCrypto crypto, float operatingRate) { @@ -117,7 +118,7 @@ import java.util.ArrayList; // dropped frames allowed, this is not desired behavior. Hence we skip (rather than drop) // frames up to the current playback position [Internal: b/66494991]. skipToPositionBeforeRenderingFirstFrame = getState() == Renderer.STATE_STARTED; - super.configureCodec(codecInfo, codec, format, crypto, operatingRate); + super.configureCodec(codecInfo, codecAdapter, format, crypto, operatingRate); } @Override From 3ec4ec4dac40b826e114d2d3e87c4d3073be009b Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 12 Jun 2020 22:02:34 +0100 Subject: [PATCH 0474/1052] Make ChunkExtractor.read return a boolean instead of an int PiperOrigin-RevId: 316172860 --- .../exoplayer2/source/chunk/BundledChunkExtractor.java | 6 ++++-- .../android/exoplayer2/source/chunk/ChunkExtractor.java | 6 +++--- .../exoplayer2/source/chunk/ContainerMediaChunk.java | 7 +------ .../exoplayer2/source/chunk/InitializationChunk.java | 7 +------ 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java index 0e2eae27d2..83a6da844f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java @@ -105,8 +105,10 @@ public final class BundledChunkExtractor implements ExtractorOutput, ChunkExtrac } @Override - public int read(ExtractorInput input) throws IOException { - return extractor.read(input, DUMMY_POSITION_HOLDER); + public boolean read(ExtractorInput input) throws IOException { + int result = extractor.read(input, DUMMY_POSITION_HOLDER); + Assertions.checkState(result != Extractor.RESULT_SEEK); + return result == Extractor.RESULT_CONTINUE; } // ExtractorOutput implementation. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java index e0ae50bd86..215e965ca0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java @@ -19,7 +19,6 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ChunkIndex; -import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.TrackOutput; import java.io.IOException; @@ -79,8 +78,9 @@ public interface ChunkExtractor { * Reads from the given {@link ExtractorInput}. * * @param input The input to read from. - * @return One of the {@link Extractor}{@code .RESULT_*} values. + * @return Whether there is any data left to extract. Returns false if the end of input has been + * reached. * @throws IOException If an error occurred reading from or parsing the input. */ - int read(ExtractorInput input) throws IOException; + boolean read(ExtractorInput input) throws IOException; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 57e5687337..b8938deac4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -24,7 +24,6 @@ import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -127,11 +126,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { dataSource, loadDataSpec.position, dataSource.open(loadDataSpec)); // Load and decode the sample data. try { - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = chunkExtractor.read(input); - } - Assertions.checkState(result != Extractor.RESULT_SEEK); + while (!loadCanceled && chunkExtractor.read(input)) {} } finally { nextLoadPosition = input.getPosition() - dataSpec.position; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index fb33e940f2..944b25395a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -24,7 +24,6 @@ import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -93,11 +92,7 @@ public final class InitializationChunk extends Chunk { dataSource, loadDataSpec.position, dataSource.open(loadDataSpec)); // Load and decode the initialization data. try { - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = chunkExtractor.read(input); - } - Assertions.checkState(result != Extractor.RESULT_SEEK); + while (!loadCanceled && chunkExtractor.read(input)) {} } finally { nextLoadPosition = input.getPosition() - dataSpec.position; } From cd54e3e584299131afdeba13683d61a19cbbea78 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 15 Jun 2020 10:09:38 +0100 Subject: [PATCH 0475/1052] Clarify usage of default period-in-window offset in tests. Using the default offset as a magic constant makes tests hard to understand. Improve that by looking up the value from the timeline or setting it explicitly in multiple places, so the relationship becomes clear. PiperOrigin-RevId: 316421977 --- .../google/android/exoplayer2/ExoPlayerTest.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index e02af5ffea..c4325762d8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -489,7 +489,7 @@ public final class ExoPlayerTest { public void adGroupWithLoadErrorIsSkipped() throws Exception { AdPlaybackState initialAdPlaybackState = FakeTimeline.createAdPlaybackState( - /* adsPerAdGroup= */ 1, /* adGroupTimesUs=... */ + /* adsPerAdGroup= */ 1, /* adGroupTimesUs...= */ TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + 5 * C.MICROS_PER_SECOND); Timeline fakeTimeline = @@ -499,7 +499,11 @@ public final class ExoPlayerTest { /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, /* durationUs= */ C.MICROS_PER_SECOND, + /* defaultPositionUs= */ 0, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, initialAdPlaybackState)); AdPlaybackState errorAdPlaybackState = initialAdPlaybackState.withAdLoadError(0, 0); final Timeline adErrorTimeline = @@ -509,7 +513,11 @@ public final class ExoPlayerTest { /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, /* durationUs= */ C.MICROS_PER_SECOND, + /* defaultPositionUs= */ 0, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, errorAdPlaybackState)); final FakeMediaSource fakeMediaSource = new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); @@ -660,7 +668,7 @@ public final class ExoPlayerTest { @Test public void internalDiscontinuityAtInitialPosition() throws Exception { - FakeTimeline timeline = new FakeTimeline(1); + FakeTimeline timeline = new FakeTimeline(/* windowCount= */ 1); FakeMediaSource mediaSource = new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { @Override @@ -672,8 +680,9 @@ public final class ExoPlayerTest { EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + // Set a discontinuity at the position this period is supposed to start at anyway. mediaPeriod.setDiscontinuityPositionUs( - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US); + timeline.getWindow(/* windowIndex= */ 0, new Window()).positionInFirstPeriodUs); return mediaPeriod; } }; From 9719b66d20bd1d8e2563fdbdfe96d181da383330 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 15 Jun 2020 11:54:43 +0100 Subject: [PATCH 0476/1052] Pull IMA cuePoints -> adGroupTimesUs logic into a helper class We're then able to use this same helper class from tests, to avoid running into spurious failures caused by long microseconds being round-tripped through float seconds. PiperOrigin-RevId: 316435084 --- .../ext/ima/AdPlaybackStateFactory.java | 56 +++++++++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 26 +--- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 116 +++++++++--------- 3 files changed, 118 insertions(+), 80 deletions(-) create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java new file mode 100644 index 0000000000..3c1b6954aa --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.ima; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import java.util.Arrays; +import java.util.List; + +/** + * Static utility class for constructing {@link AdPlaybackState} instances from IMA-specific data. + */ +/* package */ final class AdPlaybackStateFactory { + private AdPlaybackStateFactory() {} + + /** + * Construct an {@link AdPlaybackState} from the provided {@code cuePoints}. + * + * @param cuePoints The cue points of the ads in seconds. + * @return The {@link AdPlaybackState}. + */ + public static AdPlaybackState fromCuePoints(List cuePoints) { + if (cuePoints.isEmpty()) { + // If no cue points are specified, there is a preroll ad. + return new AdPlaybackState(/* adGroupTimesUs...= */ 0); + } + + int count = cuePoints.size(); + long[] adGroupTimesUs = new long[count]; + int adGroupIndex = 0; + for (int i = 0; i < count; i++) { + double cuePoint = cuePoints.get(i); + if (cuePoint == -1.0) { + adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; + } else { + adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); + } + } + // Cue points may be out of order, so sort them. + Arrays.sort(adGroupTimesUs, 0, adGroupIndex); + return new AdPlaybackState(adGroupTimesUs); + } +} diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 51e6ed6f8f..013791844b 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -662,7 +662,7 @@ public final class ImaAdsLoader adsManager.resume(); } } else if (adsManager != null) { - adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); updateAdPlaybackState(); } else { // Ads haven't loaded yet, so request them. @@ -739,7 +739,7 @@ public final class ImaAdsLoader if (player != null) { // If a player is attached already, start playback immediately. try { - adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); hasAdPlaybackState = true; updateAdPlaybackState(); } catch (RuntimeException e) { @@ -1554,28 +1554,6 @@ public final class ImaAdsLoader : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } - private static long[] getAdGroupTimesUs(List cuePoints) { - if (cuePoints.isEmpty()) { - // If no cue points are specified, there is a preroll ad. - return new long[] {0}; - } - - int count = cuePoints.size(); - long[] adGroupTimesUs = new long[count]; - int adGroupIndex = 0; - for (int i = 0; i < count; i++) { - double cuePoint = cuePoints.get(i); - if (cuePoint == -1.0) { - adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; - } else { - adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); - } - } - // Cue points may be out of order, so sort them. - Arrays.sort(adGroupTimesUs, 0, adGroupIndex); - return adGroupTimesUs; - } - private static boolean isAdGroupLoadError(AdError adError) { // TODO: Find out what other errors need to be handled (if any), and whether each one relates to // a single ad, ad group or the whole timeline. diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 906dfeb5fb..1a85f15371 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -59,6 +59,7 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.time.Duration; @@ -94,7 +95,7 @@ public final class ImaAdsLoaderTest { private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; + private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f); @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -257,7 +258,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPostrollFetchError_marksAdAsInErrorState() { - setupPlayback(CONTENT_TIMELINE, new Float[] {-1f}); + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(-1f)); // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -279,9 +280,8 @@ public final class ImaAdsLoaderTest { long adGroupTimeUs = adGroupPositionInWindowUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, - new Float[] {(float) adGroupTimeUs / C.MICROS_PER_SECOND}); + ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); // Advance playback to just before the midroll and simulate buffering. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -293,7 +293,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ adGroupTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -304,9 +304,8 @@ public final class ImaAdsLoaderTest { long adGroupTimeUs = adGroupPositionInWindowUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, - new Float[] {(float) adGroupTimeUs / C.MICROS_PER_SECOND}); + ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); // Advance playback to just before the midroll and simulate buffering. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -318,7 +317,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ adGroupTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -330,8 +329,9 @@ public final class ImaAdsLoaderTest { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; long midrollPeriodTimeUs = midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -339,7 +339,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -348,8 +348,9 @@ public final class ImaAdsLoaderTest { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; long midrollPeriodTimeUs = midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -362,7 +363,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -372,8 +373,9 @@ public final class ImaAdsLoaderTest { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; long midrollPeriodTimeUs = midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -386,7 +388,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -401,12 +403,11 @@ public final class ImaAdsLoaderTest { long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }); + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -414,8 +415,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -429,12 +429,11 @@ public final class ImaAdsLoaderTest { long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }); + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -447,8 +446,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -458,9 +456,11 @@ public final class ImaAdsLoaderTest { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; long midrollPeriodTimeUs = midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -478,7 +478,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -488,9 +488,11 @@ public final class ImaAdsLoaderTest { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; long midrollPeriodTimeUs = midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -508,7 +510,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -518,9 +520,11 @@ public final class ImaAdsLoaderTest { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; long midrollPeriodTimeUs = midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -533,7 +537,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsManager).destroy(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withSkippedAdGroup(/* adGroupIndex= */ 1)); @@ -550,12 +554,13 @@ public final class ImaAdsLoaderTest { long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -573,8 +578,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -589,12 +593,13 @@ public final class ImaAdsLoaderTest { long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -612,8 +617,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -630,7 +634,7 @@ public final class ImaAdsLoaderTest { inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } - private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { + private void setupPlayback(Timeline contentTimeline, List cuePoints) { setupPlayback( contentTimeline, cuePoints, @@ -641,10 +645,10 @@ public final class ImaAdsLoaderTest { } private void setupPlayback( - Timeline contentTimeline, Float[] cuePoints, ImaAdsLoader imaAdsLoader) { + Timeline contentTimeline, List cuePoints, ImaAdsLoader imaAdsLoader) { fakeExoPlayer = new FakePlayer(); adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); - when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + when(mockAdsManager.getAdCuePoints()).thenReturn(cuePoints); this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); } From 5b28cb520943bbb0f3da0779a784cca7b42ff5b8 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 15 Jun 2020 22:03:22 +0100 Subject: [PATCH 0477/1052] Add getMediaItem() to MediaSource This change adds MediaSource.getMediaItem and deprecates MediaSource.getTag. For backwards compatibility, the tag is made available through the Window with `mediaItem.playbackProperties.tag` as well as in the deprecated `tag` attribute. PiperOrigin-RevId: 316539752 --- .../source/ClippingMediaSource.java | 11 +++++++ .../source/ConcatenatingMediaSource.java | 4 +-- .../source/ExtractorMediaSource.java | 10 ++++++ .../exoplayer2/source/LoopingMediaSource.java | 11 +++++++ .../exoplayer2/source/MaskingMediaSource.java | 16 ++++++--- .../exoplayer2/source/MediaSource.java | 9 ++++- .../exoplayer2/source/MergingMediaSource.java | 13 ++++++++ .../source/ProgressiveMediaSource.java | 9 +++-- .../exoplayer2/source/SilenceMediaSource.java | 18 ++++++---- .../source/SingleSampleMediaSource.java | 7 +++- .../exoplayer2/source/ads/AdsMediaSource.java | 11 +++++++ .../android/exoplayer2/ExoPlayerTest.java | 7 +++- .../exoplayer2/MediaSourceListTest.java | 33 +++++++++++++++++-- .../source/dash/DashMediaSource.java | 7 +++- .../exoplayer2/source/hls/HlsMediaSource.java | 7 +++- .../source/smoothstreaming/SsMediaSource.java | 7 +++- .../exoplayer2/testutil/FakeMediaSource.java | 9 +++-- .../exoplayer2/testutil/FakeTimeline.java | 3 +- 18 files changed, 165 insertions(+), 27 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java index d4ede3e59e..14ccbead92 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -184,12 +185,22 @@ public final class ClippingMediaSource extends CompositeMediaSource { window = new Timeline.Window(); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return mediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return mediaSource.getMediaItem(); + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 8268798ee3..d664219bf3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -442,7 +442,7 @@ public final class ConcatenatingMediaSource extends CompositeMediaSource { continueLoadingCheckIntervalBytes); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return progressiveMediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return progressiveMediaSource.getMediaItem(); + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java index 13f9758a73..6d08147a63 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.AbstractConcatenatedTimeline; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ShuffleOrder.UnshuffledShuffleOrder; @@ -65,12 +66,22 @@ public final class LoopingMediaSource extends CompositeMediaSource { mediaPeriodToChildMediaPeriodId = new HashMap<>(); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return maskingMediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return maskingMediaSource.getMediaItem(); + } + @Override @Nullable public Timeline getInitialTimeline() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index db8d7e85bf..ee88725193 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.source; -import android.net.Uri; import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -68,10 +67,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { initialTimeline, /* firstWindowUid= */ null, /* firstPeriodUid= */ null); hasRealTimeline = true; } else { - // TODO(bachinger) Use mediasSource.getMediaItem() to provide the media item. - timeline = - MaskingTimeline.createWithDummyTimeline( - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(mediaSource.getTag()).build()); + timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getMediaItem()); } } @@ -89,12 +85,22 @@ public final class MaskingMediaSource extends CompositeMediaSource { } } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return mediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return mediaSource.getMediaItem(); + } + @Override @SuppressWarnings("MissingSuperCall") public void maybeThrowSourceInfoRefreshError() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java index 479db2adc2..8e860f6ed1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source; import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.upstream.Allocator; @@ -273,12 +274,18 @@ public interface MediaSource { return true; } - /** Returns the tag set on the media source, or null if none was set. */ + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @Deprecated @Nullable default Object getTag() { return null; } + /** Returns the {@link MediaItem} whose media is provided by the source. */ + MediaItem getMediaItem(); + /** * Registers a {@link MediaSourceCaller}. Starts source preparation if needed and enables the * source for the creation of {@link MediaPeriod MediaPerods}. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java index d69c037a5a..446f7132b0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -65,6 +66,8 @@ public final class MergingMediaSource extends CompositeMediaSource { } private static final int PERIOD_COUNT_UNSET = -1; + private static final MediaItem DUMMY_MEDIA_ITEM = + new MediaItem.Builder().setMediaId("MergingMediaSource").build(); private final boolean adjustPeriodTimeOffsets; private final MediaSource[] mediaSources; @@ -121,12 +124,22 @@ public final class MergingMediaSource extends CompositeMediaSource { periodTimeOffsetsUs = new long[0][]; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return mediaSources.length > 0 ? mediaSources[0].getTag() : null; } + @Override + public MediaItem getMediaItem() { + return mediaSources.length > 0 ? mediaSources[0].getMediaItem() : DUMMY_MEDIA_ITEM; + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index bfec295568..ffa8ede140 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -244,14 +244,19 @@ public final class ProgressiveMediaSource extends BaseMediaSource this.timelineDurationUs = C.TIME_UNSET; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return playbackProperties.tag; } - @Nullable - public Object getMediaItem() { + @Override + public MediaItem getMediaItem() { return mediaItem; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index a7669ae345..b3653bbc29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -77,7 +77,7 @@ public final class SilenceMediaSource extends BaseMediaSource { } /** The media id used by any media item of silence media sources. */ - public static final String MEDIA_ID = "com.google.android.exoplayer2.source.SilenceMediaSource"; + public static final String MEDIA_ID = "SilenceMediaSource"; private static final int SAMPLE_RATE_HZ = 44100; @C.PcmEncoding private static final int PCM_ENCODING = C.ENCODING_PCM_16BIT; @@ -145,18 +145,22 @@ public final class SilenceMediaSource extends BaseMediaSource { @Override public void releasePeriod(MediaPeriod mediaPeriod) {} - /** Returns the {@link MediaItem} of this media source. */ - // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. - public MediaItem getMediaItem() { - return mediaItem; - } - + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Nullable @Override public Object getTag() { return Assertions.checkNotNull(mediaItem.playbackProperties).tag; } + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + @Override protected void releaseSourceInternal() {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 2edb1a2baa..815e4d95b1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -294,13 +294,18 @@ public final class SingleSampleMediaSource extends BaseMediaSource { // MediaSource implementation. + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return castNonNull(mediaItem.playbackProperties).tag; } - // TODO(bachinger) Add @Override annotation once the method is defined by MediaSource. + @Override public MediaItem getMediaItem() { return mediaItem; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 07a46f06a9..27df9a66f3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -22,6 +22,7 @@ import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -181,12 +182,22 @@ public final class AdsMediaSource extends CompositeMediaSource { adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return contentMediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return contentMediaSource.getMediaItem(); + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index c4325762d8..506d56a89c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -140,7 +140,7 @@ public final class ExoPlayerTest { public void playEmptyTimeline() throws Exception { Timeline timeline = Timeline.EMPTY; Timeline expectedMaskingTimeline = - new MaskingMediaSource.DummyTimeline(FakeTimeline.FAKE_MEDIA_ITEM); + new MaskingMediaSource.DummyTimeline(FakeMediaSource.FAKE_MEDIA_ITEM); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_UNKNOWN); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) @@ -3445,6 +3445,11 @@ public final class ExoPlayerTest { return false; } + @Override + public MediaItem getMediaItem() { + return underlyingSource.getMediaItem(); + } + @Override @Nullable public Timeline getInitialTimeline() { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java index 7ece4f3259..bcea053115 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.source.MediaSource; @@ -42,6 +43,8 @@ import org.junit.runner.RunWith; public class MediaSourceListTest { private static final int MEDIA_SOURCE_LIST_SIZE = 4; + private static final MediaItem MINIMAL_MEDIA_ITEM = + new MediaItem.Builder().setMediaId("").build(); private MediaSourceList mediaSourceList; @@ -76,7 +79,9 @@ public class MediaSourceListTest { @Test public void prepareAndReprepareAfterRelease_expectSourcePreparationAfterMediaSourceListPrepare() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); mediaSourceList.setMediaSources( createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2), @@ -115,7 +120,9 @@ public class MediaSourceListTest { ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2); MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List mediaSources = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); @@ -132,8 +139,10 @@ public class MediaSourceListTest { } // Set media items again. The second holder is re-used. + MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List moreMediaSources = - createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class)); + createFakeHoldersWithSources(/* useLazyPreparation= */ false, mockMediaSource3); moreMediaSources.add(mediaSources.get(1)); timeline = mediaSourceList.setMediaSources(moreMediaSources, shuffleOrder); @@ -157,7 +166,9 @@ public class MediaSourceListTest { ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2); MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List mediaSources = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); @@ -174,8 +185,10 @@ public class MediaSourceListTest { any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); // Set media items again. The second holder is re-used. + MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List moreMediaSources = - createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class)); + createFakeHoldersWithSources(/* useLazyPreparation= */ false, mockMediaSource3); moreMediaSources.add(mediaSources.get(1)); mediaSourceList.setMediaSources(moreMediaSources, shuffleOrder); @@ -193,7 +206,9 @@ public class MediaSourceListTest { @Test public void addMediaSources_mediaSourceListUnprepared_notUsingLazyPreparation_expectUnprepared() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List mediaSources = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); @@ -228,7 +243,9 @@ public class MediaSourceListTest { @Test public void addMediaSources_mediaSourceListPrepared_notUsingLazyPreparation_expectPrepared() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); mediaSourceList.prepare(/* mediaTransferListener= */ null); mediaSourceList.addMediaSources( /* index= */ 0, @@ -287,9 +304,13 @@ public class MediaSourceListTest { @Test public void removeMediaSources_whenUnprepared_expectNoRelease() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource4 = mock(MediaSource.class); + when(mockMediaSource4.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); @@ -319,9 +340,13 @@ public class MediaSourceListTest { @Test public void removeMediaSources_whenPrepared_expectRelease() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource4 = mock(MediaSource.class); + when(mockMediaSource4.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); @@ -350,6 +375,7 @@ public class MediaSourceListTest { @Test public void release_mediaSourceListUnprepared_expectSourcesNotReleased() { MediaSource mockMediaSource = mock(MediaSource.class); + when(mockMediaSource.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSourceList.MediaSourceHolder mediaSourceHolder = new MediaSourceList.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false); @@ -367,6 +393,7 @@ public class MediaSourceListTest { @Test public void release_mediaSourceListPrepared_expectSourcesReleasedNotRemoved() { MediaSource mockMediaSource = mock(MediaSource.class); + when(mockMediaSource.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSourceList.MediaSourceHolder mediaSourceHolder = new MediaSourceList.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false); @@ -387,7 +414,9 @@ public class MediaSourceListTest { ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List holders = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index f60e1c15a2..747a24ca63 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -655,13 +655,18 @@ public final class DashMediaSource extends BaseMediaSource { // MediaSource implementation. + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return playbackProperties.tag; } - // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + @Override public MediaItem getMediaItem() { return mediaItem; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index fcf4386492..e2d7e47665 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -425,13 +425,18 @@ public final class HlsMediaSource extends BaseMediaSource this.useSessionKeys = useSessionKeys; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return playbackProperties.tag; } - // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + @Override public MediaItem getMediaItem() { return mediaItem; } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index f75be283e2..6b9a00b486 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -593,13 +593,18 @@ public final class SsMediaSource extends BaseMediaSource // MediaSource implementation. + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return playbackProperties.tag; } - // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + @Override public MediaItem getMediaItem() { return mediaItem; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index 1027d7d620..2c5a471c58 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -71,7 +71,7 @@ public class FakeMediaSource extends BaseMediaSource { /** The media item used by the fake media source. */ public static final MediaItem FAKE_MEDIA_ITEM = - new MediaItem.Builder().setUri("http://manifest.uri").build(); + new MediaItem.Builder().setMediaId("FakeMediaSource").setUri("http://manifest.uri").build(); private static final DataSpec FAKE_DATA_SPEC = new DataSpec(castNonNull(FAKE_MEDIA_ITEM.playbackProperties).uri); @@ -134,6 +134,11 @@ public class FakeMediaSource extends BaseMediaSource { return timeline; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { @@ -143,7 +148,7 @@ public class FakeMediaSource extends BaseMediaSource { return timeline.getWindow(0, new Timeline.Window()).tag; } - // TODO(bachinger): add @Override annotation once the method is defined by MediaSource. + @Override public MediaItem getMediaItem() { if (timeline == null || timeline.isEmpty()) { return FAKE_MEDIA_ITEM; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 81d8844372..3fc9143fa7 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -184,7 +184,8 @@ public final class FakeTimeline extends Timeline { } /** The fake media item used by the fake timeline. */ - public static final MediaItem FAKE_MEDIA_ITEM = new MediaItem.Builder().setUri(Uri.EMPTY).build(); + public static final MediaItem FAKE_MEDIA_ITEM = + new MediaItem.Builder().setMediaId("FakeTimeline").setUri(Uri.EMPTY).build(); private static final long AD_DURATION_US = 10 * C.MICROS_PER_SECOND; From b7233c28e974ba413b1ee389355e8a1ae1940962 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 16 Jun 2020 11:34:12 +0100 Subject: [PATCH 0478/1052] Add Player.getCurrentMediaItem() PiperOrigin-RevId: 316650017 --- RELEASENOTES.md | 4 +- .../google/android/exoplayer2/BasePlayer.java | 22 ++++++- .../com/google/android/exoplayer2/Player.java | 15 ++++- .../android/exoplayer2/ExoPlayerTest.java | 57 +++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index acc6338b44..53acfb8193 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -74,7 +74,9 @@ * `SimpleDecoderVideoRenderer` and `SimpleDecoderAudioRenderer` renamed to `DecoderVideoRenderer` and `DecoderAudioRenderer` respectively, and generalized to work with `Decoder` rather than `SimpleDecoder`. - * Add media item based playlist API to Player. + * Add media item based playlist API to `Player`. + * Add `getCurrentMediaItem` to `Player`. + * Remove deprecated members in `DefaultTrackSelector`. * Add `Player.DeviceComponent` and implement it for `SimpleExoPlayer` so that the device volume can be controlled by player. * Parse track titles from Matroska files diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java index 5692b1dae7..893c512bd7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java @@ -158,11 +158,31 @@ public abstract class BasePlayer implements Player { getCurrentWindowIndex(), getRepeatModeForNavigation(), getShuffleModeEnabled()); } + /** + * @deprecated Use {@link #getCurrentMediaItem()} and {@link MediaItem.PlaybackProperties#tag} + * instead. + */ + @Deprecated @Override @Nullable public final Object getCurrentTag() { Timeline timeline = getCurrentTimeline(); - return timeline.isEmpty() ? null : timeline.getWindow(getCurrentWindowIndex(), window).tag; + if (timeline.isEmpty()) { + return null; + } + @Nullable + MediaItem.PlaybackProperties playbackProperties = + timeline.getWindow(getCurrentWindowIndex(), window).mediaItem.playbackProperties; + return playbackProperties != null ? playbackProperties.tag : null; + } + + @Override + @Nullable + public final MediaItem getCurrentMediaItem() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() + ? null + : timeline.getWindow(getCurrentWindowIndex(), window).mediaItem; } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index ee941428b8..1a136b2d30 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -1242,10 +1242,19 @@ public interface Player { int getPreviousWindowIndex(); /** - * Returns the tag of the currently playing window in the timeline. May be null if no tag is set - * or the timeline is not yet available. + * @deprecated Use {@link #getCurrentMediaItem()} and {@link MediaItem.PlaybackProperties#tag} + * instead. */ - @Nullable Object getCurrentTag(); + @Deprecated + @Nullable + Object getCurrentTag(); + + /** + * Returns the media item of the current window in the timeline. May be null if the timeline is + * empty. + */ + @Nullable + MediaItem getCurrentMediaItem(); /** * Returns the duration of the current content window or ad in milliseconds, or {@link diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 506d56a89c..30af89dd08 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -53,6 +53,7 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.SilenceMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdPlaybackState; @@ -6709,6 +6710,62 @@ public final class ExoPlayerTest { .isEqualTo(rendererStreamOffsetsUs.get(0) + periodDurationUs); } + @Test + public void mediaItemOfSources_correctInTimelineWindows() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + final Player[] playerHolder = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + playerHolder[0] = player; + } + }) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* positionMs= */ 0) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + List currentMediaItems = new ArrayList<>(); + List initialMediaItems = new ArrayList<>(); + Player.EventListener eventListener = + new Player.EventListener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + if (reason != Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { + return; + } + Window window = new Window(); + for (int i = 0; i < timeline.getWindowCount(); i++) { + initialMediaItems.add(timeline.getWindow(i, window).mediaItem); + } + } + + @Override + public void onPositionDiscontinuity(int reason) { + currentMediaItems.add(playerHolder[0].getCurrentMediaItem()); + } + }; + new ExoPlayerTestRunner.Builder(context) + .setEventListener(eventListener) + .setActionSchedule(actionSchedule) + .setMediaSources( + factory.setTag("1").createMediaSource(), + factory.setTag("2").createMediaSource(), + factory.setTag("3").createMediaSource()) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(currentMediaItems.get(0).playbackProperties.tag).isEqualTo("1"); + assertThat(currentMediaItems.get(1).playbackProperties.tag).isEqualTo("2"); + assertThat(currentMediaItems.get(2).playbackProperties.tag).isEqualTo("3"); + assertThat(initialMediaItems).containsExactlyElementsIn(currentMediaItems); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { From cc97bcb469b2c9e55ee029f40230794375d3dfbb Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 16 Jun 2020 15:14:45 +0100 Subject: [PATCH 0479/1052] Move runUntil method to TestUtil as it's used by multiple tests. We started using this method from other tests unrelated to TestExoPlayer, so the method is better placed inside a generic Util class. PiperOrigin-RevId: 316675067 --- .../source/ProgressiveMediaPeriodTest.java | 4 +- .../exoplayer2/testutil/TestExoPlayer.java | 108 ++++-------------- .../android/exoplayer2/testutil/TestUtil.java | 78 +++++++++++++ .../testutil/TestExoPlayerTest.java | 75 ------------ .../exoplayer2/testutil/TestUtilTest.java | 48 ++++++++ 5 files changed, 153 insertions(+), 160 deletions(-) delete mode 100644 testutils/src/test/java/com/google/android/exoplayer2/testutil/TestExoPlayerTest.java diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java index 0478f77367..420fcb6fcc 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; import android.net.Uri; @@ -24,7 +25,6 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; -import com.google.android.exoplayer2.testutil.TestExoPlayer; import com.google.android.exoplayer2.upstream.AssetDataSource; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; @@ -72,7 +72,7 @@ public final class ProgressiveMediaPeriodTest { } }, /* positionUs= */ 0); - TestExoPlayer.runUntil(prepareCallbackCalled::get); + runMainLooperUntil(prepareCallbackCalled::get); mediaPeriod.release(); assertThat(sourceInfoRefreshCalledBeforeOnPrepared.get()).isTrue(); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java index a8672b703f..139088aeb6 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.testutil; +import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; import android.content.Context; @@ -36,11 +37,8 @@ import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.Supplier; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoListener; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -52,30 +50,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public class TestExoPlayer { - /** - * The default timeout applied when calling one of the {@code runUntil} methods. This timeout - * should be sufficient for any condition using a Robolectric test. - */ - public static final long DEFAULT_TIMEOUT_MS = 10_000; - - /** Reflectively call Robolectric ShadowLooper#runOneTask. */ - private static final Object shadowLooper; - - private static final Method runOneTaskMethod; - - static { - try { - Class clazz = Class.forName("org.robolectric.Shadows"); - Method shadowOfMethod = - Assertions.checkNotNull(clazz.getDeclaredMethod("shadowOf", Looper.class)); - shadowLooper = - Assertions.checkNotNull(shadowOfMethod.invoke(new Object(), Looper.getMainLooper())); - runOneTaskMethod = shadowLooper.getClass().getDeclaredMethod("runOneTask"); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - /** A builder of {@link SimpleExoPlayer} instances for testing. */ public static class Builder { @@ -317,7 +291,8 @@ public class TestExoPlayer { * * @param player The {@link Player}. * @param expectedState The expected {@link Player.State}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static void runUntilPlaybackState(Player player, @Player.State int expectedState) throws TimeoutException { @@ -336,7 +311,7 @@ public class TestExoPlayer { } }; player.addListener(listener); - runUntil(receivedExpectedState::get); + runMainLooperUntil(receivedExpectedState::get); player.removeListener(listener); } @@ -346,7 +321,8 @@ public class TestExoPlayer { * * @param player The {@link Player}. * @param expectedPlayWhenReady The expected value for {@link Player#getPlayWhenReady()}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static void runUntilPlayWhenReady(Player player, boolean expectedPlayWhenReady) throws TimeoutException { @@ -366,7 +342,7 @@ public class TestExoPlayer { } }; player.addListener(listener); - runUntil(receivedExpectedPlayWhenReady::get); + runMainLooperUntil(receivedExpectedPlayWhenReady::get); } /** @@ -375,7 +351,8 @@ public class TestExoPlayer { * * @param player The {@link Player}. * @param expectedTimeline The expected {@link Timeline}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static void runUntilTimelineChanged(Player player, Timeline expectedTimeline) throws TimeoutException { @@ -395,7 +372,7 @@ public class TestExoPlayer { } }; player.addListener(listener); - runUntil(receivedExpectedTimeline::get); + runMainLooperUntil(receivedExpectedTimeline::get); } /** @@ -403,7 +380,8 @@ public class TestExoPlayer { * * @param player The {@link Player}. * @return The new {@link Timeline}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static Timeline runUntilTimelineChanged(Player player) throws TimeoutException { verifyMainTestThread(player); @@ -417,7 +395,7 @@ public class TestExoPlayer { } }; player.addListener(listener); - runUntil(() -> receivedTimeline.get() != null); + runMainLooperUntil(() -> receivedTimeline.get() != null); return receivedTimeline.get(); } @@ -428,7 +406,8 @@ public class TestExoPlayer { * * @param player The {@link Player}. * @param expectedReason The expected {@link Player.DiscontinuityReason}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static void runUntilPositionDiscontinuity( Player player, @Player.DiscontinuityReason int expectedReason) throws TimeoutException { @@ -444,7 +423,7 @@ public class TestExoPlayer { } }; player.addListener(listener); - runUntil(receivedCallback::get); + runMainLooperUntil(receivedCallback::get); } /** @@ -452,7 +431,8 @@ public class TestExoPlayer { * * @param player The {@link Player}. * @return The raised {@link ExoPlaybackException}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static ExoPlaybackException runUntilError(Player player) throws TimeoutException { verifyMainTestThread(player); @@ -466,7 +446,7 @@ public class TestExoPlayer { } }; player.addListener(listener); - runUntil(() -> receivedError.get() != null); + runMainLooperUntil(() -> receivedError.get() != null); return receivedError.get(); } @@ -475,7 +455,8 @@ public class TestExoPlayer { * callback has been called. * * @param player The {@link Player}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static void runUntilRenderedFirstFrame(SimpleExoPlayer player) throws TimeoutException { verifyMainTestThread(player); @@ -489,7 +470,7 @@ public class TestExoPlayer { } }; player.addVideoListener(listener); - runUntil(receivedCallback::get); + runMainLooperUntil(receivedCallback::get); } /** @@ -497,7 +478,8 @@ public class TestExoPlayer { * commands on the internal playback thread. * * @param player The {@link Player}. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS default timeout} is exceeded. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. */ public static void runUntilPendingCommandsAreFullyHandled(ExoPlayer player) throws TimeoutException { @@ -510,41 +492,7 @@ public class TestExoPlayer { .createMessage((type, data) -> receivedMessageCallback.set(true)) .setHandler(Util.createHandler()) .send(); - runUntil(receivedMessageCallback::get); - } - - /** - * Runs tasks of the main {@link Looper} until the {@code condition} returns {@code true}. - * - * @param condition The condition. - * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS} is exceeded. - */ - public static void runUntil(Supplier condition) throws TimeoutException { - runUntil(condition, DEFAULT_TIMEOUT_MS, Clock.DEFAULT); - } - - /** - * Runs tasks of the main {@link Looper} until the {@code condition} returns {@code true}. - * - * @param condition The condition. - * @param timeoutMs The timeout in milliseconds. - * @param clock The {@link Clock} to measure the timeout. - * @throws TimeoutException If the {@code timeoutMs timeout} is exceeded. - */ - public static void runUntil(Supplier condition, long timeoutMs, Clock clock) - throws TimeoutException { - verifyMainTestThread(); - try { - long timeoutTimeMs = clock.currentTimeMillis() + timeoutMs; - while (!condition.get()) { - if (clock.currentTimeMillis() >= timeoutTimeMs) { - throw new TimeoutException(); - } - runOneTaskMethod.invoke(shadowLooper); - } - } catch (IllegalAccessException | InvocationTargetException e) { - throw new IllegalStateException(e); - } + runMainLooperUntil(receivedMessageCallback::get); } private static void verifyMainTestThread(Player player) { @@ -553,10 +501,4 @@ public class TestExoPlayer { throw new IllegalStateException(); } } - - private static void verifyMainTestThread() { - if (Looper.myLooper() != Looper.getMainLooper()) { - throw new IllegalStateException(); - } - } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 0aac047e44..1a53d300d7 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -26,6 +26,7 @@ import android.graphics.BitmapFactory; import android.graphics.Color; import android.media.MediaCodec; import android.net.Uri; +import android.os.Looper; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.database.DefaultDatabaseProvider; @@ -40,21 +41,38 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.Supplier; import com.google.android.exoplayer2.util.SystemClock; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Random; +import java.util.concurrent.TimeoutException; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Utility methods for tests. */ public class TestUtil { + /** + * The default timeout applied when calling {@link #runMainLooperUntil(Supplier)}. This timeout + * should be sufficient for any condition using a Robolectric test. + */ + public static final long DEFAULT_TIMEOUT_MS = 10_000; + + /** Reflectively loaded Robolectric ShadowLooper#runOneTask. */ + private static @MonotonicNonNull Object shadowLooper; + + private static @MonotonicNonNull Method runOneTaskMethod; + private TestUtil() {} /** @@ -484,4 +502,64 @@ public class TestUtil { } }); } + + /** + * Runs tasks of the main Robolectric {@link Looper} until the {@code condition} returns {@code + * true}. + * + *

        Must be called on the main test thread. + * + * @param condition The condition. + * @throws TimeoutException If the {@link #DEFAULT_TIMEOUT_MS} is exceeded. + */ + public static void runMainLooperUntil(Supplier condition) throws TimeoutException { + runMainLooperUntil(condition, DEFAULT_TIMEOUT_MS, Clock.DEFAULT); + } + + /** + * Runs tasks of the main Robolectric {@link Looper} until the {@code condition} returns {@code + * true}. + * + * @param condition The condition. + * @param timeoutMs The timeout in milliseconds. + * @param clock The {@link Clock} to measure the timeout. + * @throws TimeoutException If the {@code timeoutMs timeout} is exceeded. + */ + public static void runMainLooperUntil(Supplier condition, long timeoutMs, Clock clock) + throws TimeoutException { + if (Looper.myLooper() != Looper.getMainLooper()) { + throw new IllegalStateException(); + } + maybeInitShadowLooperAndRunOneTaskMethod(); + try { + long timeoutTimeMs = clock.currentTimeMillis() + timeoutMs; + while (!condition.get()) { + if (clock.currentTimeMillis() >= timeoutTimeMs) { + throw new TimeoutException(); + } + runOneTaskMethod.invoke(shadowLooper); + } + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } catch (InvocationTargetException e) { + throw new IllegalStateException(e.getCause()); + } + } + + @EnsuresNonNull({"shadowLooper", "runOneTaskMethod"}) + private static void maybeInitShadowLooperAndRunOneTaskMethod() { + if (shadowLooper != null && runOneTaskMethod != null) { + return; + } + try { + Class clazz = Class.forName("org.robolectric.Shadows"); + Method shadowOfMethod = + Assertions.checkNotNull(clazz.getDeclaredMethod("shadowOf", Looper.class)); + shadowLooper = + Assertions.checkNotNull(shadowOfMethod.invoke(new Object(), Looper.getMainLooper())); + runOneTaskMethod = shadowLooper.getClass().getDeclaredMethod("runOneTask"); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } } diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestExoPlayerTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestExoPlayerTest.java deleted file mode 100644 index 3e18222562..0000000000 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestExoPlayerTest.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.testutil; - -import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.atMost; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.Supplier; -import java.util.concurrent.TimeoutException; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; - -/** Unit test for {@link TestExoPlayer}. */ -@RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) -public final class TestExoPlayerTest { - - @Test - public void runUntil_withConditionAlreadyTrue_returnsImmediately() throws Exception { - Clock mockClock = mock(Clock.class); - - TestExoPlayer.runUntil(() -> true, /* timeoutMs= */ 0, mockClock); - - verify(mockClock, atMost(1)).currentTimeMillis(); - } - - @Test - public void runUntil_withConditionThatNeverBecomesTrue_timesOut() { - Clock mockClock = mock(Clock.class); - when(mockClock.currentTimeMillis()).thenReturn(0L, 41L, 42L); - - assertThrows( - TimeoutException.class, - () -> TestExoPlayer.runUntil(() -> false, /* timeoutMs= */ 42, mockClock)); - - verify(mockClock, times(3)).currentTimeMillis(); - } - - @SuppressWarnings("unchecked") - @Test - public void runUntil_whenConditionBecomesTrueAfterDelay_returnsWhenConditionBecomesTrue() - throws Exception { - Supplier mockCondition = mock(Supplier.class); - when(mockCondition.get()) - .thenReturn(false) - .thenReturn(false) - .thenReturn(false) - .thenReturn(false) - .thenReturn(true); - - TestExoPlayer.runUntil(mockCondition, /* timeoutMs= */ 5674, mock(Clock.class)); - - verify(mockCondition, times(5)).get(); - } -} diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java index 0a999c4161..01a119b694 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java @@ -16,9 +16,18 @@ package com.google.android.exoplayer2.testutil; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.Supplier; +import java.util.concurrent.TimeoutException; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,4 +52,43 @@ public class TestUtilTest { long endTimeMs = System.currentTimeMillis(); assertThat(endTimeMs - startTimeMs).isAtLeast(500); } + + @Test + public void runMainLooperUntil_withConditionAlreadyTrue_returnsImmediately() throws Exception { + Clock mockClock = mock(Clock.class); + + TestUtil.runMainLooperUntil(() -> true, /* timeoutMs= */ 0, mockClock); + + verify(mockClock, atMost(1)).currentTimeMillis(); + } + + @Test + public void runMainLooperUntil_withConditionThatNeverBecomesTrue_timesOut() { + Clock mockClock = mock(Clock.class); + when(mockClock.currentTimeMillis()).thenReturn(0L, 41L, 42L); + + assertThrows( + TimeoutException.class, + () -> TestUtil.runMainLooperUntil(() -> false, /* timeoutMs= */ 42, mockClock)); + + verify(mockClock, times(3)).currentTimeMillis(); + } + + @SuppressWarnings("unchecked") + @Test + public void + runMainLooperUntil_whenConditionBecomesTrueAfterDelay_returnsWhenConditionBecomesTrue() + throws Exception { + Supplier mockCondition = mock(Supplier.class); + when(mockCondition.get()) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(true); + + TestUtil.runMainLooperUntil(mockCondition, /* timeoutMs= */ 5674, mock(Clock.class)); + + verify(mockCondition, times(5)).get(); + } } From aed8cad8c3d06ba26f8556e09c200e1993e167a5 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 16 Jun 2020 18:11:53 +0100 Subject: [PATCH 0480/1052] Remove unused waitingForKeys in renderers. This flag isn't needed anymore because the waiting for keys happens on the source side and the source just returns NOTHING_READ under the same conditions. PiperOrigin-RevId: 316704214 --- .../audio/DecoderAudioRenderer.java | 71 +++++----------- .../mediacodec/MediaCodecRenderer.java | 49 +++-------- .../video/DecoderVideoRenderer.java | 85 ++++++------------- 3 files changed, 63 insertions(+), 142 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index 9f1fe07c39..d580d0fbc0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -123,7 +123,6 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media private boolean allowPositionDiscontinuity; private boolean inputStreamEnded; private boolean outputStreamEnded; - private boolean waitingForKeys; public DecoderAudioRenderer() { this(/* eventHandler= */ null, /* eventListener= */ null); @@ -399,52 +398,30 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media return false; } - @SampleStream.ReadDataResult int result; FormatHolder formatHolder = getFormatHolder(); - if (waitingForKeys) { - // We've already read an encrypted sample into buffer, and are waiting for keys. - result = C.RESULT_BUFFER_READ; - } else { - result = readSource(formatHolder, inputBuffer, false); + switch (readSource(formatHolder, inputBuffer, /* formatRequired= */ false)) { + case C.RESULT_NOTHING_READ: + return false; + case C.RESULT_FORMAT_READ: + onInputFormatChanged(formatHolder); + return true; + case C.RESULT_BUFFER_READ: + if (inputBuffer.isEndOfStream()) { + inputStreamEnded = true; + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + return false; + } + inputBuffer.flip(); + onQueueInputBuffer(inputBuffer); + decoder.queueInputBuffer(inputBuffer); + decoderReceivedBuffers = true; + decoderCounters.inputBufferCount++; + inputBuffer = null; + return true; + default: + throw new IllegalStateException(); } - - if (result == C.RESULT_NOTHING_READ) { - return false; - } - if (result == C.RESULT_FORMAT_READ) { - onInputFormatChanged(formatHolder); - return true; - } - if (inputBuffer.isEndOfStream()) { - inputStreamEnded = true; - decoder.queueInputBuffer(inputBuffer); - inputBuffer = null; - return false; - } - boolean bufferEncrypted = inputBuffer.isEncrypted(); - waitingForKeys = shouldWaitForKeys(bufferEncrypted); - if (waitingForKeys) { - return false; - } - inputBuffer.flip(); - onQueueInputBuffer(inputBuffer); - decoder.queueInputBuffer(inputBuffer); - decoderReceivedBuffers = true; - decoderCounters.inputBufferCount++; - inputBuffer = null; - return true; - } - - private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (decoderDrmSession == null - || (!bufferEncrypted && decoderDrmSession.playClearSamplesWithoutKeys())) { - return false; - } - @DrmSession.State int drmSessionState = decoderDrmSession.getState(); - if (drmSessionState == DrmSession.STATE_ERROR) { - throw createRendererException(decoderDrmSession.getError(), inputFormat); - } - return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } private void processEndOfStream() throws ExoPlaybackException { @@ -458,7 +435,6 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media } private void flushDecoder() throws ExoPlaybackException { - waitingForKeys = false; if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { releaseDecoder(); maybeInitDecoder(); @@ -481,7 +457,7 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media @Override public boolean isReady() { return audioSink.hasPendingData() - || (inputFormat != null && !waitingForKeys && (isSourceReady() || outputBuffer != null)); + || (inputFormat != null && (isSourceReady() || outputBuffer != null)); } @Override @@ -543,7 +519,6 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media protected void onDisabled() { inputFormat = null; audioTrackNeedsConfigure = true; - waitingForKeys = false; try { setSourceDrmSession(null); releaseDecoder(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 6f35f21583..465accd65f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -401,7 +401,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private long lastBufferInStreamPresentationTimeUs; private boolean inputStreamEnded; private boolean outputStreamEnded; - private boolean waitingForKeys; private boolean waitingForFirstSyncSample; private boolean waitingForFirstSampleInFormat; private boolean pendingOutputEndOfStream; @@ -880,7 +879,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { shouldSkipAdaptationWorkaroundOutputBuffer = false; isDecodeOnlyOutputBuffer = false; isLastOutputBuffer = false; - waitingForKeys = false; decodeOnlyPresentationTimestamps.clear(); largestQueuedPresentationTimeUs = C.TIME_UNSET; lastBufferInStreamPresentationTimeUs = C.TIME_UNSET; @@ -1235,25 +1233,20 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return true; } - @SampleStream.ReadDataResult int result; - FormatHolder formatHolder = getFormatHolder(); - int adaptiveReconfigurationBytes = 0; - if (waitingForKeys) { - // We've already read an encrypted sample into buffer, and are waiting for keys. - result = C.RESULT_BUFFER_READ; - } else { - // For adaptive reconfiguration, decoders expect all reconfiguration data to be supplied at - // the start of the buffer that also contains the first frame in the new format. - if (codecReconfigurationState == RECONFIGURATION_STATE_WRITE_PENDING) { - for (int i = 0; i < codecFormat.initializationData.size(); i++) { - byte[] data = codecFormat.initializationData.get(i); - buffer.data.put(data); - } - codecReconfigurationState = RECONFIGURATION_STATE_QUEUE_PENDING; + // For adaptive reconfiguration, decoders expect all reconfiguration data to be supplied at + // the start of the buffer that also contains the first frame in the new format. + if (codecReconfigurationState == RECONFIGURATION_STATE_WRITE_PENDING) { + for (int i = 0; i < codecFormat.initializationData.size(); i++) { + byte[] data = codecFormat.initializationData.get(i); + buffer.data.put(data); } - adaptiveReconfigurationBytes = buffer.data.position(); - result = readSource(formatHolder, buffer, false); + codecReconfigurationState = RECONFIGURATION_STATE_QUEUE_PENDING; } + int adaptiveReconfigurationBytes = buffer.data.position(); + + FormatHolder formatHolder = getFormatHolder(); + @SampleStream.ReadDataResult + int result = readSource(formatHolder, buffer, /* formatRequired= */ false); if (hasReadStreamToEnd()) { // Notify output queue of the last buffer's timestamp. @@ -1321,14 +1314,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { waitingForFirstSyncSample = false; boolean bufferEncrypted = buffer.isEncrypted(); - waitingForKeys = shouldWaitForKeys(bufferEncrypted); - if (waitingForKeys) { - return false; - } if (bufferEncrypted) { buffer.cryptoInfo.increaseClearDataFirstSubSampleBy(adaptiveReconfigurationBytes); } - if (codecNeedsDiscardToSpsWorkaround && !bufferEncrypted) { NalUnitUtil.discardToSps(buffer.data); if (buffer.data.position() == 0) { @@ -1385,18 +1373,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return true; } - private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (codecDrmSession == null - || (!bufferEncrypted && codecDrmSession.playClearSamplesWithoutKeys())) { - return false; - } - @DrmSession.State int drmSessionState = codecDrmSession.getState(); - if (drmSessionState == DrmSession.STATE_ERROR) { - throw createRendererException(codecDrmSession.getError(), inputFormat); - } - return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; - } - /** * Called when a {@link MediaCodec} has been created and configured. *

        @@ -1624,7 +1600,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override public boolean isReady() { return inputFormat != null - && !waitingForKeys && (isSourceReady() || hasOutputBuffer() || (codecHotswapDeadlineMs != C.TIME_UNSET diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java index d4163acc38..ddf5c4b98e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java @@ -116,7 +116,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { private boolean renderedFirstFrameAfterEnable; private long initialPositionUs; private long joiningDeadlineMs; - private boolean waitingForKeys; private boolean waitingForFirstSampleInFormat; private boolean inputStreamEnded; @@ -211,9 +210,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { @Override public boolean isReady() { - if (waitingForKeys) { - return false; - } if (inputFormat != null && (isSourceReady() || outputBuffer != null) && (renderedFirstFrameAfterReset || !hasOutput())) { @@ -293,7 +289,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { @Override protected void onDisabled() { inputFormat = null; - waitingForKeys = false; clearReportedVideoSize(); clearRenderedFirstFrame(); try { @@ -336,7 +331,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { */ @CallSuper protected void flushDecoder() throws ExoPlaybackException { - waitingForKeys = false; buffersInCodecCount = 0; if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { releaseDecoder(); @@ -726,46 +720,36 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { return false; } - @SampleStream.ReadDataResult int result; FormatHolder formatHolder = getFormatHolder(); - if (waitingForKeys) { - // We've already read an encrypted sample into buffer, and are waiting for keys. - result = C.RESULT_BUFFER_READ; - } else { - result = readSource(formatHolder, inputBuffer, false); + switch (readSource(formatHolder, inputBuffer, /* formatRequired= */ false)) { + case C.RESULT_NOTHING_READ: + return false; + case C.RESULT_FORMAT_READ: + onInputFormatChanged(formatHolder); + return true; + case C.RESULT_BUFFER_READ: + if (inputBuffer.isEndOfStream()) { + inputStreamEnded = true; + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + return false; + } + if (waitingForFirstSampleInFormat) { + formatQueue.add(inputBuffer.timeUs, inputFormat); + waitingForFirstSampleInFormat = false; + } + inputBuffer.flip(); + inputBuffer.format = inputFormat; + onQueueInputBuffer(inputBuffer); + decoder.queueInputBuffer(inputBuffer); + buffersInCodecCount++; + decoderReceivedBuffers = true; + decoderCounters.inputBufferCount++; + inputBuffer = null; + return true; + default: + throw new IllegalStateException(); } - - if (result == C.RESULT_NOTHING_READ) { - return false; - } - if (result == C.RESULT_FORMAT_READ) { - onInputFormatChanged(formatHolder); - return true; - } - if (inputBuffer.isEndOfStream()) { - inputStreamEnded = true; - decoder.queueInputBuffer(inputBuffer); - inputBuffer = null; - return false; - } - boolean bufferEncrypted = inputBuffer.isEncrypted(); - waitingForKeys = shouldWaitForKeys(bufferEncrypted); - if (waitingForKeys) { - return false; - } - if (waitingForFirstSampleInFormat) { - formatQueue.add(inputBuffer.timeUs, inputFormat); - waitingForFirstSampleInFormat = false; - } - inputBuffer.flip(); - inputBuffer.format = inputFormat; - onQueueInputBuffer(inputBuffer); - decoder.queueInputBuffer(inputBuffer); - buffersInCodecCount++; - decoderReceivedBuffers = true; - decoderCounters.inputBufferCount++; - inputBuffer = null; - return true; } /** @@ -903,19 +887,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { maybeRenotifyRenderedFirstFrame(); } - private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - DrmSession decoderDrmSession = this.decoderDrmSession; - if (decoderDrmSession == null - || (!bufferEncrypted && decoderDrmSession.playClearSamplesWithoutKeys())) { - return false; - } - @DrmSession.State int drmSessionState = decoderDrmSession.getState(); - if (drmSessionState == DrmSession.STATE_ERROR) { - throw createRendererException(decoderDrmSession.getError(), inputFormat); - } - return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; - } - private void setJoiningDeadlineMs() { joiningDeadlineMs = allowedJoiningTimeMs > 0 From 2273b00a5330d33baa1c624b6021b068702c4f20 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Tue, 16 Jun 2020 18:38:04 +0100 Subject: [PATCH 0481/1052] Create HlsMediaChunkExtractor To be the abstraction to use for integrating with MediaParser. PiperOrigin-RevId: 316710421 --- .../hls/BundledHlsMediaChunkExtractor.java | 101 ++++++++++++++++++ .../hls/DefaultHlsExtractorFactory.java | 58 +--------- .../source/hls/HlsExtractorFactory.java | 33 +----- .../exoplayer2/source/hls/HlsMediaChunk.java | 35 +++--- .../source/hls/HlsMediaChunkExtractor.java | 59 ++++++++++ .../hls/DefaultHlsExtractorFactoryTest.java | 32 +----- 6 files changed, 185 insertions(+), 133 deletions(-) create mode 100644 library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java create mode 100644 library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java new file mode 100644 index 0000000000..4fd77135ab --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac4Extractor; +import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; + +/** + * {@link HlsMediaChunkExtractor} implementation that uses ExoPlayer app-bundled {@link Extractor + * Extractors}. + */ +public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtractor { + + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + @VisibleForTesting /* package */ final Extractor extractor; + private final Format masterPlaylistFormat; + private final TimestampAdjuster timestampAdjuster; + + /** + * Creates a new instance. + * + * @param extractor The underlying {@link Extractor}. + * @param masterPlaylistFormat The {@link Format} obtained from the master playlist. + * @param timestampAdjuster A {@link TimestampAdjuster} to adjust sample timestamps. + */ + public BundledHlsMediaChunkExtractor( + Extractor extractor, Format masterPlaylistFormat, TimestampAdjuster timestampAdjuster) { + this.extractor = extractor; + this.masterPlaylistFormat = masterPlaylistFormat; + this.timestampAdjuster = timestampAdjuster; + } + + @Override + public void init(ExtractorOutput extractorOutput) { + extractor.init(extractorOutput); + } + + @Override + public boolean read(ExtractorInput extractorInput) throws IOException { + return extractor.read(extractorInput, DUMMY_POSITION_HOLDER) == Extractor.RESULT_CONTINUE; + } + + @Override + public boolean isPackedAudioExtractor() { + return extractor instanceof AdtsExtractor + || extractor instanceof Ac3Extractor + || extractor instanceof Ac4Extractor + || extractor instanceof Mp3Extractor; + } + + @Override + public HlsMediaChunkExtractor reuseOrRecreate() { + if (extractor instanceof TsExtractor || extractor instanceof FragmentedMp4Extractor) { + // We can reuse this instance. + return this; + } + Extractor newExtractorInstance; + if (extractor instanceof WebvttExtractor) { + newExtractorInstance = new WebvttExtractor(masterPlaylistFormat.language, timestampAdjuster); + } else if (extractor instanceof AdtsExtractor) { + newExtractorInstance = new AdtsExtractor(); + } else if (extractor instanceof Ac3Extractor) { + newExtractorInstance = new Ac3Extractor(); + } else if (extractor instanceof Ac4Extractor) { + newExtractorInstance = new Ac4Extractor(); + } else if (extractor instanceof Mp3Extractor) { + newExtractorInstance = new Mp3Extractor(); + } else { + throw new IllegalStateException( + "Unexpected previousExtractor type: " + extractor.getClass().getSimpleName()); + } + return new BundledHlsMediaChunkExtractor( + newExtractorInstance, masterPlaylistFormat, timestampAdjuster); + } +} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 3176551a45..0fe89d4a4e 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -88,8 +88,8 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } @Override - public Result createExtractor( - @Nullable Extractor previousExtractor, + public BundledHlsMediaChunkExtractor createExtractor( + @Nullable HlsMediaChunkExtractor previousExtractor, Uri uri, Format format, @Nullable List muxedCaptionFormats, @@ -97,22 +97,6 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { Map> responseHeaders, ExtractorInput extractorInput) throws IOException { - - if (previousExtractor != null) { - // An extractor has already been successfully used. Return one of the same type. - if (isReusable(previousExtractor)) { - return buildResult(previousExtractor); - } else { - @Nullable - Result result = - buildResultForSameExtractorType(previousExtractor, format, timestampAdjuster); - if (result == null) { - throw new IllegalArgumentException( - "Unexpected previousExtractor type: " + previousExtractor.getClass().getSimpleName()); - } - } - } - @FileTypes.Type int formatInferredFileType = FileTypes.inferFileTypeFromMimeType(format.sampleMimeType); @FileTypes.Type @@ -139,14 +123,15 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { checkNotNull( createExtractorByFileType(fileType, format, muxedCaptionFormats, timestampAdjuster)); if (sniffQuietly(extractor, extractorInput)) { - return buildResult(extractor); + return new BundledHlsMediaChunkExtractor(extractor, format, timestampAdjuster); } if (fileType == FileTypes.TS) { fallBackExtractor = extractor; } } - return buildResult(checkNotNull(fallBackExtractor)); + return new BundledHlsMediaChunkExtractor( + checkNotNull(fallBackExtractor), format, timestampAdjuster); } private static void addFileTypeIfNotPresent( @@ -257,34 +242,6 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { return false; } - @Nullable - private static Result buildResultForSameExtractorType( - Extractor previousExtractor, Format format, TimestampAdjuster timestampAdjuster) { - if (previousExtractor instanceof WebvttExtractor) { - return buildResult(new WebvttExtractor(format.language, timestampAdjuster)); - } else if (previousExtractor instanceof AdtsExtractor) { - return buildResult(new AdtsExtractor()); - } else if (previousExtractor instanceof Ac3Extractor) { - return buildResult(new Ac3Extractor()); - } else if (previousExtractor instanceof Ac4Extractor) { - return buildResult(new Ac4Extractor()); - } else if (previousExtractor instanceof Mp3Extractor) { - return buildResult(new Mp3Extractor()); - } else { - return null; - } - } - - private static Result buildResult(Extractor extractor) { - return new Result( - extractor, - extractor instanceof AdtsExtractor - || extractor instanceof Ac3Extractor - || extractor instanceof Ac4Extractor - || extractor instanceof Mp3Extractor, - isReusable(extractor)); - } - private static boolean sniffQuietly(Extractor extractor, ExtractorInput input) throws IOException { boolean result = false; @@ -297,9 +254,4 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } return result; } - - private static boolean isReusable(Extractor previousExtractor) { - return previousExtractor instanceof TsExtractor - || previousExtractor instanceof FragmentedMp4Extractor; - } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java index eb3cf8bfcf..de2ecd73b4 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java @@ -31,33 +31,6 @@ import java.util.Map; */ public interface HlsExtractorFactory { - /** Holds an {@link Extractor} and associated parameters. */ - final class Result { - - /** The created extractor; */ - public final Extractor extractor; - /** Whether the segments for which {@link #extractor} is created are packed audio segments. */ - public final boolean isPackedAudioExtractor; - /** - * Whether {@link #extractor} may be reused for following continuous (no immediately preceding - * discontinuities) segments of the same variant. - */ - public final boolean isReusable; - - /** - * Creates a result. - * - * @param extractor See {@link #extractor}. - * @param isPackedAudioExtractor See {@link #isPackedAudioExtractor}. - * @param isReusable See {@link #isReusable}. - */ - public Result(Extractor extractor, boolean isPackedAudioExtractor, boolean isReusable) { - this.extractor = extractor; - this.isPackedAudioExtractor = isPackedAudioExtractor; - this.isReusable = isReusable; - } - } - HlsExtractorFactory DEFAULT = new DefaultHlsExtractorFactory(); /** @@ -76,11 +49,11 @@ public interface HlsExtractorFactory { * @param sniffingExtractorInput The first extractor input that will be passed to the returned * extractor's {@link Extractor#read(ExtractorInput, PositionHolder)}. Must only be used to * call {@link Extractor#sniff(ExtractorInput)}. - * @return A {@link Result}. + * @return An {@link HlsMediaChunkExtractor}. * @throws IOException If an I/O error is encountered while sniffing. */ - Result createExtractor( - @Nullable Extractor previousExtractor, + HlsMediaChunkExtractor createExtractor( + @Nullable HlsMediaChunkExtractor previousExtractor, Uri uri, Format format, @Nullable List muxedCaptionFormats, diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 4e87e717bf..4c3b7655a7 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -21,9 +21,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; -import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.id3.PrivFrame; @@ -56,8 +54,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Creates a new instance. * - * @param extractorFactory A {@link HlsExtractorFactory} from which the HLS media chunk extractor - * is obtained. + * @param extractorFactory A {@link HlsExtractorFactory} from which the {@link + * HlsMediaChunkExtractor} is obtained. * @param dataSource The source from which the data should be loaded. * @param format The chunk format. * @param startOfPlaylistInPeriodUs The position of the playlist in the period in microseconds. @@ -130,7 +128,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int discontinuitySequenceNumber = mediaPlaylist.discontinuitySequence + mediaSegment.relativeDiscontinuitySequence; - @Nullable Extractor previousExtractor = null; + @Nullable HlsMediaChunkExtractor previousExtractor = null; Id3Decoder id3Decoder; ParsableByteArray scratchId3Data; boolean shouldSpliceIn; @@ -147,8 +145,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueueDiscardFromIndices = previousChunk.sampleQueueDiscardFromIndices; } previousExtractor = - previousChunk.isExtractorReusable - && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber + previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber && !shouldSpliceIn ? previousChunk.extractor : null; @@ -188,7 +185,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public static final String PRIV_TIMESTAMP_FRAME_OWNER = "com.apple.streaming.transportStreamTimestamp"; - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); private static final AtomicInteger uidSource = new AtomicInteger(); @@ -207,7 +203,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Nullable private final DataSource initDataSource; @Nullable private final DataSpec initDataSpec; - @Nullable private final Extractor previousExtractor; + @Nullable private final HlsMediaChunkExtractor previousExtractor; private final boolean isMasterTimestampSource; private final boolean hasGapTag; @@ -221,8 +217,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final boolean initSegmentEncrypted; private final boolean shouldSpliceIn; - private @MonotonicNonNull Extractor extractor; - private boolean isExtractorReusable; + private @MonotonicNonNull HlsMediaChunkExtractor extractor; private @MonotonicNonNull HlsSampleStreamWrapper output; // nextLoadPosition refers to the init segment if initDataLoadRequired is true. // Otherwise, nextLoadPosition refers to the media segment. @@ -253,7 +248,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; boolean isMasterTimestampSource, TimestampAdjuster timestampAdjuster, @Nullable DrmInitData drmInitData, - @Nullable Extractor previousExtractor, + @Nullable HlsMediaChunkExtractor previousExtractor, Id3Decoder id3Decoder, ParsableByteArray scratchId3Data, boolean shouldSpliceIn, @@ -340,9 +335,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // output == null means init() hasn't been called. Assertions.checkNotNull(output); if (extractor == null && previousExtractor != null) { - extractor = previousExtractor; - isExtractorReusable = true; - initDataLoadRequired = false; + extractor = previousExtractor.reuseOrRecreate(); + initDataLoadRequired = extractor != previousExtractor; } maybeLoadInitData(); if (!loadCanceled) { @@ -410,10 +404,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; input.skipFully(nextLoadPosition); } try { - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, DUMMY_POSITION_HOLDER); - } + while (!loadCanceled && extractor.read(input)) {} } finally { nextLoadPosition = (int) (input.getPosition() - dataSpec.position); } @@ -434,7 +425,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; long id3Timestamp = peekId3PrivTimestamp(extractorInput); extractorInput.resetPeekPosition(); - HlsExtractorFactory.Result result = + extractor = extractorFactory.createExtractor( previousExtractor, dataSpec.uri, @@ -443,9 +434,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; timestampAdjuster, dataSource.getResponseHeaders(), extractorInput); - extractor = result.extractor; - isExtractorReusable = result.isReusable; - if (result.isPackedAudioExtractor) { + if (extractor.isPackedAudioExtractor()) { output.setSampleOffsetUs( id3Timestamp != C.TIME_UNSET ? timestampAdjuster.adjustTsTimestamp(id3Timestamp) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java new file mode 100644 index 0000000000..55f69b7e6c --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java @@ -0,0 +1,59 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import java.io.IOException; + +/** Extracts samples and track {@link Format Formats} from {@link HlsMediaChunk HlsMediaChunks}. */ +public interface HlsMediaChunkExtractor { + + /** + * Initializes the extractor with an {@link ExtractorOutput}. Called at most once. + * + * @param extractorOutput An {@link ExtractorOutput} to receive extracted data. + */ + void init(ExtractorOutput extractorOutput); + + /** + * Extracts data read from a provided {@link ExtractorInput}. Must not be called before {@link + * #init(ExtractorOutput)}. + * + *

        A single call to this method will block until some progress has been made, but will not + * block for longer than this. Hence each call will consume only a small amount of input data. + * + *

        When this method throws an {@link IOException}, extraction may continue by providing an + * {@link ExtractorInput} with an unchanged {@link ExtractorInput#getPosition() read position} to + * a subsequent call to this method. + * + * @param extractorInput The input to read from. + * @return Whether there is any data left to extract. Returns false if the end of input has been + * reached. + * @throws IOException If an error occurred reading from or parsing the input. + */ + boolean read(ExtractorInput extractorInput) throws IOException; + + /** Returns whether this is a packed audio extractor, as defined in RFC 8216, Section 3.4. */ + boolean isPackedAudioExtractor(); + + /** + * If this instance can be used for extracting multiple continuous segments, returns itself. + * Otherwise, returns a new instance for extracting the same type of media. + */ + HlsMediaChunkExtractor reuseOrRecreate(); +} diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java index d5f33424ab..b3cd7caac4 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java @@ -22,10 +22,8 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; -import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.testutil.FakeExtractorInput; @@ -44,7 +42,6 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class DefaultHlsExtractorFactoryTest { - private Extractor fMp4Extractor; private Uri tsUri; private Format webVttFormat; private TimestampAdjuster timestampAdjuster; @@ -52,7 +49,6 @@ public class DefaultHlsExtractorFactoryTest { @Before public void setUp() { - fMp4Extractor = new FragmentedMp4Extractor(); tsUri = Uri.parse("http://path/filename.ts"); webVttFormat = new Format.Builder().setSampleMimeType(MimeTypes.TEXT_VTT).build(); timestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); @@ -60,24 +56,6 @@ public class DefaultHlsExtractorFactoryTest { ac3ResponseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.AUDIO_AC3)); } - @Test - public void createExtractor_withPreviousExtractor_returnsSameExtractorType() throws Exception { - ExtractorInput extractorInput = new FakeExtractorInput.Builder().build(); - - HlsExtractorFactory.Result result = - new DefaultHlsExtractorFactory() - .createExtractor( - /* previousExtractor= */ fMp4Extractor, - tsUri, - webVttFormat, - /* muxedCaptionFormats= */ null, - timestampAdjuster, - ac3ResponseHeaders, - extractorInput); - - assertThat(result.extractor.getClass()).isEqualTo(FragmentedMp4Extractor.class); - } - @Test public void createExtractor_withFileTypeInFormat_returnsExtractorMatchingFormat() throws Exception { @@ -88,7 +66,7 @@ public class DefaultHlsExtractorFactoryTest { ApplicationProvider.getApplicationContext(), "webvtt/typical")) .build(); - HlsExtractorFactory.Result result = + BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( /* previousExtractor= */ null, @@ -112,7 +90,7 @@ public class DefaultHlsExtractorFactoryTest { TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), "ts/sample.ac3")) .build(); - HlsExtractorFactory.Result result = + BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( /* previousExtractor= */ null, @@ -135,7 +113,7 @@ public class DefaultHlsExtractorFactoryTest { ApplicationProvider.getApplicationContext(), "ts/sample_ac3.ts")) .build(); - HlsExtractorFactory.Result result = + BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( /* previousExtractor= */ null, @@ -159,7 +137,7 @@ public class DefaultHlsExtractorFactoryTest { ApplicationProvider.getApplicationContext(), "mp3/bear-id3.mp3")) .build(); - HlsExtractorFactory.Result result = + BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( /* previousExtractor= */ null, @@ -177,7 +155,7 @@ public class DefaultHlsExtractorFactoryTest { public void createExtractor_withNoMatchingExtractor_fallsBackOnTsExtractor() throws Exception { ExtractorInput emptyExtractorInput = new FakeExtractorInput.Builder().build(); - HlsExtractorFactory.Result result = + BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( /* previousExtractor= */ null, From e6b6a86a7786b1b8db0e9018d459cecacade2d34 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 16 Jun 2020 18:46:18 +0100 Subject: [PATCH 0482/1052] Remove support for MKV invisible flag We haven't seen it used anywhere in practice. It's a niche feature not supported by any other extractors, and is one of the very few things stopping us from simplifying MediaSource implementations to not set the decodeOnly sample flag. This is a simplification that we want to make, since the current mechanism doesn't work properly for cases where a downstream decoder adjusts the buffer presentation timestamps so that they're different on the output side than on the input side. PiperOrigin-RevId: 316712302 --- RELEASENOTES.md | 1 + .../android/exoplayer2/extractor/mkv/MatroskaExtractor.java | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 53acfb8193..125ca8ccc9 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -176,6 +176,7 @@ timestamps ([#7464](https://github.com/google/ExoPlayer/issues/7464)). * Ogg: Allow non-contiguous pages ([#7230](https://github.com/google/ExoPlayer/issues/7230)). +* Matroska: Remove support for "Invisible" block header flag. * Extractors: * Add `IndexSeeker` for accurate seeks in VBR MP3 streams ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 6d28e870a4..de6d1c19c6 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -1196,11 +1196,9 @@ public class MatroskaExtractor implements Extractor { int timecode = (scratch.data[0] << 8) | (scratch.data[1] & 0xFF); blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode); - boolean isInvisible = (scratch.data[2] & 0x08) == 0x08; boolean isKeyframe = track.type == TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2] & 0x80) == 0x80); - blockFlags = (isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0) - | (isInvisible ? C.BUFFER_FLAG_DECODE_ONLY : 0); + blockFlags = isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; blockState = BLOCK_STATE_DATA; blockSampleIndex = 0; } From c808db99359950b7d5e2c2b3e6200a9e565ce575 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 16 Jun 2020 18:58:04 +0100 Subject: [PATCH 0483/1052] Add MIME types for which every sample is known to be a sync sample. - Leaving the TODO, since there are still MIME types we're unsure about. - Removing AAC because xHE-AAC does not have this property. We may re-add it with an additional profile check to exclude xHE-AAC in the future. PiperOrigin-RevId: 316715147 --- .../google/android/exoplayer2/util/MimeTypes.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index f2b160f368..cbc06d157d 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -192,12 +192,22 @@ public final class MimeTypes { if (mimeType == null) { return false; } - // TODO: Consider adding additional audio MIME types here. + // TODO: Add additional audio MIME types. Also consider evaluating based on Format rather than + // just MIME type, since in some cases the property is true for a subset of the profiles + // belonging to a single MIME type. If we do this, we should move the method to a different + // class. See [Internal ref: http://go/exo-audio-format-random-access]. switch (mimeType) { - case AUDIO_AAC: case AUDIO_MPEG: case AUDIO_MPEG_L1: case AUDIO_MPEG_L2: + case AUDIO_RAW: + case AUDIO_ALAW: + case AUDIO_MLAW: + case AUDIO_OPUS: + case AUDIO_FLAC: + case AUDIO_AC3: + case AUDIO_E_AC3: + case AUDIO_E_AC3_JOC: return true; default: return false; From f85098a88f441afa89693cd77d748fbd9046e32e Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 16 Jun 2020 23:04:33 +0100 Subject: [PATCH 0484/1052] Update deprecation JavaDoc for ExoPlayer DataSpec constructor to note that the builder does NOT infer the http method from the existence of the post body. PiperOrigin-RevId: 316765677 --- .../java/com/google/android/exoplayer2/upstream/DataSpec.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index 395df63529..75e23ae6f2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java @@ -518,7 +518,8 @@ public final class DataSpec { * set to {@link #HTTP_METHOD_POST}. If {@code postBody} is null then {@link #httpMethod} is set * to {@link #HTTP_METHOD_GET}. * - * @deprecated Use {@link Builder}. + * @deprecated Use {@link Builder}. Note that the httpMethod must be set explicitly for the + * Builder. * @param uri {@link #uri}. * @param postBody {@link #httpBody} The body of the HTTP request, which is also used to infer the * {@link #httpMethod}. From 28695d9ab56dbd4a0940949900deccb5a7a57e01 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 07:57:27 +0100 Subject: [PATCH 0485/1052] Move IMA SDK callbacks into inner class The release() method was added in the recent IMA API changes for preloading and now 'collides' with the ExoPlayer AdsLoader release method. This led to all ads completing being treated as a call to completely release the ads loader, which meant that the ad playback state was not updated on resuming after all ads had completed, which in turn led to playback getting stuck buffering on returning from the background after all ads played. Move the IMA callbacks into an inner class to avoid this. Issue: #7508 PiperOrigin-RevId: 316834561 --- RELEASENOTES.md | 3 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 615 +++++++++--------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 82 ++- 3 files changed, 373 insertions(+), 327 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 125ca8ccc9..e268181b66 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -215,6 +215,9 @@ * Add option to skip ads before the start position. * Catch unexpected errors in `stopAd` to avoid a crash ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix a bug that caused playback to be stuck buffering on resuming from + the background after all ads had played to the end + ([#7508](https://github.com/google/ExoPlayer/issues/7508)). * Demo app: Retain previous position in list of samples. * Add Guava dependency. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 013791844b..98718752bf 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -86,14 +86,7 @@ import java.util.Set; * For more details see {@link AdDisplayContainer#registerVideoControlsOverlay(View)} and {@link * AdViewProvider#getAdOverlayViews()}. */ -public final class ImaAdsLoader - implements Player.EventListener, - AdsLoader, - VideoAdPlayer, - ContentProgressProvider, - AdErrorListener, - AdsLoadedListener, - AdEventListener { +public final class ImaAdsLoader implements Player.EventListener, AdsLoader { static { ExoPlayerLibraryInfo.registerModule("goog.exo.ima"); @@ -364,12 +357,13 @@ public final class ImaAdsLoader */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link - * #pauseAd(AdMediaInfo)}. + * The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not + * {@link ComponentListener##pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. + * The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while + * playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; @@ -386,7 +380,8 @@ public final class ImaAdsLoader private final ImaFactory imaFactory; private final Timeline.Period period; private final Handler handler; - private final List adCallbacks; + private final ComponentListener componentListener; + private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; private final Runnable updateAdProgressRunnable; @@ -400,7 +395,7 @@ public final class ImaAdsLoader @Nullable private Player player; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; - private int lastVolumePercentage; + private int lastVolumePercent; @Nullable private AdsManager adsManager; private boolean isAdsManagerInitialized; @@ -443,10 +438,10 @@ public final class ImaAdsLoader */ @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd(AdMediaInfo)}, - * stores the value of {@link SystemClock#elapsedRealtime()} when the content stopped playing. - * This can be used to determine a fake, increasing content position. {@link C#TIME_UNSET} - * otherwise. + * If a content period has finished but IMA has not yet called {@link + * ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link + * SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine + * a fake, increasing content position. {@link C#TIME_UNSET} otherwise. */ private long fakeContentProgressElapsedRealtimeMs; /** @@ -456,7 +451,10 @@ public final class ImaAdsLoader private long fakeContentProgressOffsetMs; /** Stores the pending content position when a seek operation was intercepted to play an ad. */ private long pendingContentPositionMs; - /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ + /** + * Whether {@link ComponentListener#getContentProgress()} has sent {@link + * #pendingContentPositionMs} to IMA. + */ private boolean sentPendingContentPositionMs; /** * Stores the real time in milliseconds at which the player started buffering, possibly due to not @@ -528,14 +526,15 @@ public final class ImaAdsLoader imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); handler = Util.createHandler(getImaLooper(), /* callback= */ null); + componentListener = new ComponentListener(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); - adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); + adDisplayContainer.setPlayer(/* videoAdPlayer= */ componentListener); adsLoader = imaFactory.createAdsLoader( context.getApplicationContext(), imaSdkSettings, adDisplayContainer); - adsLoader.addAdErrorListener(/* adErrorListener= */ this); - adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + adsLoader.addAdErrorListener(componentListener); + adsLoader.addAdsLoadedListener(componentListener); updateAdProgressRunnable = this::updateAdProgress; adInfoByAdMediaInfo = new HashMap<>(); supportedMimeTypes = Collections.emptyList(); @@ -596,7 +595,7 @@ public final class ImaAdsLoader if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } - request.setContentProgressProvider(this); + request.setContentProgressProvider(componentListener); pendingAdRequestContext = new Object(); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); @@ -645,7 +644,7 @@ public final class ImaAdsLoader player.addListener(this); boolean playWhenReady = player.getPlayWhenReady(); this.eventListener = eventListener; - lastVolumePercentage = 0; + lastVolumePercent = 0; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); @@ -682,9 +681,9 @@ public final class ImaAdsLoader adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); } - lastVolumePercentage = getVolume(); + lastVolumePercent = getPlayerVolumePercent(); lastAdProgress = getAdVideoProgressUpdate(); - lastContentProgress = getContentProgress(); + lastContentProgress = getContentVideoProgressUpdate(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); this.player = null; @@ -695,8 +694,8 @@ public final class ImaAdsLoader public void release() { pendingAdRequestContext = null; destroyAdsManager(); - adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); - adsLoader.removeAdErrorListener(/* adErrorListener= */ this); + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; @@ -704,7 +703,7 @@ public final class ImaAdsLoader imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = false; + hasAdPlaybackState = true; updateAdPlaybackState(); } @@ -720,275 +719,6 @@ public final class ImaAdsLoader } } - // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. - - @Override - public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { - AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); - if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { - adsManager.destroy(); - return; - } - pendingAdRequestContext = null; - this.adsManager = adsManager; - adsManager.addAdErrorListener(this); - adsManager.addAdEventListener(this); - if (adEventListener != null) { - adsManager.addAdEventListener(adEventListener); - } - if (player != null) { - // If a player is attached already, start playback immediately. - try { - adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); - hasAdPlaybackState = true; - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdsManagerLoaded", e); - } - } - } - - // AdEvent.AdEventListener implementation. - - @Override - public void onAdEvent(AdEvent adEvent) { - AdEventType adEventType = adEvent.getType(); - if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { - Log.d(TAG, "onAdEvent: " + adEventType); - } - if (adsManager == null) { - // Drop events after release. - return; - } - try { - handleAdEvent(adEvent); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdEvent", e); - } - } - - // AdErrorEvent.AdErrorListener implementation. - - @Override - public void onAdError(AdErrorEvent adErrorEvent) { - AdError error = adErrorEvent.getError(); - if (DEBUG) { - Log.d(TAG, "onAdError", error); - } - if (adsManager == null) { - // No ads were loaded, so allow playback to start without any ads. - pendingAdRequestContext = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; - updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { - try { - handleAdGroupLoadError(error); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdError", e); - } - } - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAllAds(error); - } - maybeNotifyPendingAdLoadError(); - } - - // ContentProgressProvider implementation. - - @Override - public VideoProgressUpdate getContentProgress() { - VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); - if (DEBUG) { - Log.d(TAG, "Content progress: " + videoProgressUpdate); - } - - if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { - // IMA is polling the player position but we are buffering for an ad to preload, so playback - // may be stuck. Detect this case and signal an error if applicable. - long stuckElapsedRealtimeMs = - SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; - if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { - waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; - handleAdGroupLoadError(new IOException("Ad preloading timed out")); - maybeNotifyPendingAdLoadError(); - } - } - - return videoProgressUpdate; - } - - // VideoAdPlayer implementation. - - @Override - public VideoProgressUpdate getAdProgress() { - throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); - } - - @Override - public int getVolume() { - @Nullable Player player = this.player; - if (player == null) { - return lastVolumePercentage; - } - - @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); - if (audioComponent != null) { - return (int) (audioComponent.getVolume() * 100); - } - - // Check for a selected track using an audio renderer. - TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); - for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { - if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { - return 100; - } - } - return 0; - } - - @Override - public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { - try { - if (DEBUG) { - Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } - if (adsManager == null) { - // Drop events after release. - return; - } - int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); - int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; - AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - adInfoByAdMediaInfo.put(adMediaInfo, adInfo); - if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { - // We have already marked this ad as having failed to load, so ignore the request. IMA will - // timeout after its media load timeout. - return; - } - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - } - for (int i = 0; i < adIndexInAdGroup; i++) { - // Any preceding ads that haven't loaded are not going to load. - if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { - adPlaybackState = - adPlaybackState.withAdLoadError( - /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); - } - } - Uri adUri = Uri.parse(adMediaInfo.getUrl()); - adPlaybackState = - adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("loadAd", e); - } - } - - @Override - public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.add(videoAdPlayerCallback); - } - - @Override - public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.remove(videoAdPlayerCallback); - } - - @Override - public void playAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop events after release. - return; - } - - if (imaAdState == IMA_AD_STATE_PLAYING) { - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028]. - Log.w(TAG, "Unexpected playAd without stopAd"); - } - - try { - if (imaAdState == IMA_AD_STATE_NONE) { - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - imaAdMediaInfo = adMediaInfo; - imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(adMediaInfo); - } - if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { - pendingAdPrepareErrorAdInfo = null; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(adMediaInfo); - } - } - updateAdProgress(); - } else { - imaAdState = IMA_AD_STATE_PLAYING; - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(adMediaInfo); - } - } - if (!Assertions.checkNotNull(player).getPlayWhenReady()) { - Assertions.checkNotNull(adsManager).pause(); - } - } catch (RuntimeException e) { - maybeNotifyInternalError("playAd", e); - } - } - - @Override - public void stopAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop event after release. - return; - } - - try { - Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); - stopAdInternal(); - } catch (RuntimeException e) { - maybeNotifyInternalError("stopAd", e); - } - } - - @Override - public void pauseAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); - } - if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. - return; - } - - try { - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); - } - } catch (RuntimeException e) { - maybeNotifyInternalError("pauseAd", e); - } - } - // Player.EventListener implementation. @Override @@ -1265,6 +995,27 @@ public final class ImaAdsLoader handler.removeCallbacks(updateAdProgressRunnable); } + private int getPlayerVolumePercent() { + @Nullable Player player = this.player; + if (player == null) { + return lastVolumePercent; + } + + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + if (audioComponent != null) { + return (int) (audioComponent.getVolume() * 100); + } + + // Check for a selected track using an audio renderer. + TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); + for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { + return 100; + } + } + return 0; + } + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { @@ -1581,8 +1332,8 @@ public final class ImaAdsLoader private void destroyAdsManager() { if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); + adsManager.removeAdErrorListener(componentListener); + adsManager.removeAdEventListener(componentListener); if (adEventListener != null) { adsManager.removeAdEventListener(adEventListener); } @@ -1607,6 +1358,272 @@ public final class ImaAdsLoader Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } + private final class ComponentListener + implements VideoAdPlayer, + ContentProgressProvider, + AdErrorListener, + AdsLoadedListener, + AdEventListener { + + // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); + if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { + adsManager.destroy(); + return; + } + pendingAdRequestContext = null; + ImaAdsLoader.this.adsManager = adsManager; + adsManager.addAdErrorListener(this); + adsManager.addAdEventListener(this); + if (adEventListener != null) { + adsManager.addAdEventListener(adEventListener); + } + if (player != null) { + // If a player is attached already, start playback immediately. + try { + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); + hasAdPlaybackState = true; + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdsManagerLoaded", e); + } + } + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + AdEventType adEventType = adEvent.getType(); + if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { + Log.d(TAG, "onAdEvent: " + adEventType); + } + if (adsManager == null) { + // Drop events after release. + return; + } + try { + handleAdEvent(adEvent); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdEvent", e); + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + if (DEBUG) { + Log.d(TAG, "onAdError", error); + } + if (adsManager == null) { + // No ads were loaded, so allow playback to start without any ads. + pendingAdRequestContext = null; + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; + updateAdPlaybackState(); + } else if (isAdGroupLoadError(error)) { + try { + handleAdGroupLoadError(error); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdError", e); + } + } + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAllAds(error); + } + maybeNotifyPendingAdLoadError(); + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (DEBUG) { + Log.d(TAG, "Content progress: " + videoProgressUpdate); + } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } + + return videoProgressUpdate; + } + + // VideoAdPlayer implementation. + + @Override + public VideoProgressUpdate getAdProgress() { + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); + } + + @Override + public int getVolume() { + return getPlayerVolumePercent(); + } + + @Override + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + try { + if (DEBUG) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); + } + if (adsManager == null) { + // Drop events after release. + return; + } + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA + // will timeout after its media load timeout. + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + } + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = + adPlaybackState.withAdLoadError( + /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); + adPlaybackState = + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("loadAd", e); + } + } + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @Override + public void playAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop events after release. + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); + } + + try { + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null + && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { + Assertions.checkNotNull(adsManager).pause(); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); + } + } + + @Override + public void stopAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + + try { + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); + stopAdInternal(); + } catch (RuntimeException e) { + maybeNotifyInternalError("stopAd", e); + } + } + + @Override + public void pauseAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called after content is resumed. + return; + } + + try { + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); + } + } + + @Override + public void release() { + // Do nothing. + } + } + // TODO: Consider moving this into AdPlaybackState. private static final class AdInfo { public final int adGroupIndex; diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 1a85f15371..c3202a26be 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -44,6 +44,8 @@ import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; +import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; @@ -114,6 +116,9 @@ public final class ImaAdsLoaderTest { private ViewGroup adViewGroup; private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; + private AdEvent.AdEventListener adEventListener; + private ContentProgressProvider contentProgressProvider; + private VideoAdPlayer videoAdPlayer; private TestAdsLoaderListener adsLoaderListener; private FakePlayer fakeExoPlayer; private ImaAdsLoader imaAdsLoader; @@ -192,6 +197,8 @@ public final class ImaAdsLoaderTest { @Test public void startAndCallbacksAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); + // Request ads in order to get a reference to the ad event listener. + imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -202,16 +209,16 @@ public final class ImaAdsLoaderTest { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + videoAdPlayer.pauseAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); imaAdsLoader.handlePrepareError( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); } @@ -222,27 +229,27 @@ public final class ImaAdsLoaderTest { // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* position= */ 0, /* contentPosition= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) @@ -262,7 +269,7 @@ public final class ImaAdsLoaderTest { // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(mockPostrollFetchErrorAdEvent); + adEventListener.onAdEvent(mockPostrollFetchErrorAdEvent); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -289,7 +296,7 @@ public final class ImaAdsLoaderTest { fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance before the timeout and simulating polling content progress. ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); - imaAdsLoader.getContentProgress(); + contentProgressProvider.getContentProgress(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -313,7 +320,7 @@ public final class ImaAdsLoaderTest { fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance past the timeout and simulate polling content progress. ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); - imaAdsLoader.getContentProgress(); + contentProgressProvider.getContentProgress(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -660,6 +667,8 @@ public final class ImaAdsLoaderTest { .thenAnswer(invocation -> userRequestContextCaptor.getValue()); List adsLoadedListeners = new ArrayList<>(); + // Deliberately don't handle removeAdsLoadedListener to allow testing behavior if the IMA SDK + // invokes callbacks after release. doAnswer( invocation -> { adsLoadedListeners.add(invocation.getArgument(0)); @@ -667,13 +676,6 @@ public final class ImaAdsLoaderTest { }) .when(mockAdsLoader) .addAdsLoadedListener(any()); - doAnswer( - invocation -> { - adsLoadedListeners.remove(invocation.getArgument(0)); - return null; - }) - .when(mockAdsLoader) - .removeAdsLoadedListener(any()); when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); when(mockAdsManagerLoadedEvent.getUserRequestContext()) .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); @@ -689,6 +691,30 @@ public final class ImaAdsLoaderTest { .when(mockAdsLoader) .requestAds(mockAdsRequest); + doAnswer( + invocation -> { + adEventListener = invocation.getArgument(0); + return null; + }) + .when(mockAdsManager) + .addAdEventListener(any()); + + doAnswer( + invocation -> { + contentProgressProvider = invocation.getArgument(0); + return null; + }) + .when(mockAdsRequest) + .setContentProgressProvider(any()); + + doAnswer( + invocation -> { + videoAdPlayer = invocation.getArgument(0); + return null; + }) + .when(mockAdDisplayContainer) + .setPlayer(any()); + when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer); when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings); when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); From 99954b4ca083983706c533ab98bfc7c554e54075 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 17 Jun 2020 13:38:27 +0100 Subject: [PATCH 0486/1052] Deflake DecoderVideoRendererTest The test was trying to synchronize a background decoding thread by waiting for pending decode calls. However, the background thread needs to fully queue the newly available output buffer before we can stop waiting to ensure it's actually fully predictable. So we change the pending wait to wait until the input buffer is cleared, which only happens after the decoder is definitely done with it. Also properly clean-up decoder (including shutting down the background thread). PiperOrigin-RevId: 316870659 --- .../video/DecoderVideoRendererTest.java | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java index 0c1c2787a6..9fa06eed57 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java @@ -24,11 +24,11 @@ import android.graphics.SurfaceTexture; import android.os.Handler; import android.os.SystemClock; import android.view.Surface; -import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RendererConfiguration; import com.google.android.exoplayer2.decoder.DecoderException; @@ -39,6 +39,8 @@ import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.util.MimeTypes; +import java.util.concurrent.Phaser; +import org.junit.After; import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -75,11 +77,7 @@ public final class DecoderVideoRendererTest { eventListener, /* maxDroppedFramesToNotify= */ -1) { - private final Object pendingDecodeCallLock = new Object(); - - @GuardedBy("pendingDecodeCallLock") - private int pendingDecodeCalls; - + private final Phaser inputBuffersInCodecPhaser = new Phaser(); @C.VideoOutputMode private int outputMode; @Override @@ -106,29 +104,17 @@ public final class DecoderVideoRendererTest { @Override protected void onQueueInputBuffer(VideoDecoderInputBuffer buffer) { - // SimpleDecoder.decode() is called on a background thread we have no control about from - // the test. Ensure the background calls are predictably serialized by waiting for them - // to finish: - // 1. Mark decode calls as "pending" here. - // 2. Send a message on the test thread to wait for all pending decode calls. - // 3. Decrement the pending counter in decode calls and wake up the waiting test. - // 4. The tests need to call ShadowLooper.idleMainThread() to wait for pending calls. - synchronized (pendingDecodeCallLock) { - pendingDecodeCalls++; - } - new Handler() - .post( - () -> { - synchronized (pendingDecodeCallLock) { - while (pendingDecodeCalls > 0) { - try { - pendingDecodeCallLock.wait(); - } catch (InterruptedException e) { - // Ignore. - } - } - } - }); + // Decoding is done on a background thread we have no control about from the test. + // Ensure the background calls are predictably serialized by waiting for them to finish: + // 1. Register queued input buffers here. + // 2. Deregister the input buffer when it's cleared. If an input buffer is cleared it + // will have been fully handled by the decoder. + // 3. Send a message on the test thread to wait for all currently pending input buffers + // to be cleared. + // 4. The tests need to call ShadowLooper.idleMainThread() to execute the wait message + // sent in step (3). + int currentPhase = inputBuffersInCodecPhaser.register(); + new Handler().post(() -> inputBuffersInCodecPhaser.awaitAdvance(currentPhase)); super.onQueueInputBuffer(buffer); } @@ -144,7 +130,13 @@ public final class DecoderVideoRendererTest { @Override protected VideoDecoderInputBuffer createInputBuffer() { return new VideoDecoderInputBuffer( - DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); + DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT) { + @Override + public void clear() { + super.clear(); + inputBuffersInCodecPhaser.arriveAndDeregister(); + } + }; } @Override @@ -164,10 +156,6 @@ public final class DecoderVideoRendererTest { VideoDecoderOutputBuffer outputBuffer, boolean reset) { outputBuffer.init(inputBuffer.timeUs, outputMode, /* supplementalData= */ null); - synchronized (pendingDecodeCallLock) { - pendingDecodeCalls--; - pendingDecodeCallLock.notify(); - } return null; } @@ -181,6 +169,16 @@ public final class DecoderVideoRendererTest { renderer.setOutputSurface(new Surface(new SurfaceTexture(/* texName= */ 0))); } + @After + public void shutDown() throws Exception { + if (renderer.getState() == Renderer.STATE_STARTED) { + renderer.stop(); + } + if (renderer.getState() == Renderer.STATE_ENABLED) { + renderer.disable(); + } + } + @Test public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { FakeSampleStream fakeSampleStream = From 2546be51fe1f15bc018aebffe9a4b6d014eae4a8 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 13:46:22 +0100 Subject: [PATCH 0487/1052] Remove some ad playback state change requirements Ads can appear due to asynchronous ad tag requests completing after earlier ads in a pod have loaded, so remove the requirement that the ad count can't change. The MediaPeriodQueue should handling discarding buffered content if an ad appears before already buffered content, so I think this case is actually handled correctly by the core player already. Also remove the requirement that an ad URI can't change. This is a defensive measure for now, but it's likely that a later fix in the IMA SDK for an issue where loadAd is not called after preloading then seeking before a preloaded ad plays will result in loadAd being called more than once, and I think it's possible that the second call to loadAd may have a different URI. Because the ad URI should only change after an intermediate seek to another MediaPeriod, there shouldn't be any problems with buffered data not getting discarded. Issue: #7477 PiperOrigin-RevId: 316871371 --- RELEASENOTES.md | 2 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 19 +++++++++++-------- .../source/ads/AdPlaybackState.java | 14 ++------------ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e268181b66..7b002474ff 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -218,6 +218,8 @@ * Fix a bug that caused playback to be stuck buffering on resuming from the background after all ads had played to the end ([#7508](https://github.com/google/ExoPlayer/issues/7508)). + * Fix a bug where the number of ads in an ad group couldn't change + ([#7477](https://github.com/google/ExoPlayer/issues/7477)). * Demo app: Retain previous position in list of samples. * Add Guava dependency. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 98718752bf..454f9513e9 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1485,6 +1485,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { // Drop events after release. return; } + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); @@ -1494,21 +1495,23 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { // will timeout after its media load timeout. return; } + + // The ad count may increase on successive loads of ads in the same ad pod, for example, due + // to separate requests for ad tags with multiple ads within the ad pod completing after an + // earlier ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477. AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - } + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; for (int i = 0; i < adIndexInAdGroup; i++) { // Any preceding ads that haven't loaded are not going to load. if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { adPlaybackState = - adPlaybackState.withAdLoadError( - /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i); } } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); adPlaybackState = adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index d56c4eefac..70128c78bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -124,13 +124,9 @@ public final class AdPlaybackState { return result; } - /** - * Returns a new instance with the ad count set to {@code count}. This method may only be called - * if this instance's ad count has not yet been specified. - */ + /** Returns a new instance with the ad count set to {@code count}. */ @CheckResult public AdGroup withAdCount(int count) { - Assertions.checkArgument(this.count == C.LENGTH_UNSET && states.length <= count); @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); @NullableType Uri[] uris = Arrays.copyOf(this.uris, count); @@ -139,17 +135,11 @@ public final class AdPlaybackState { /** * Returns a new instance with the specified {@code uri} set for the specified ad, and the ad - * marked as {@link #AD_STATE_AVAILABLE}. The specified ad must currently be in {@link - * #AD_STATE_UNAVAILABLE}, which is the default state. - * - *

        This instance's ad count may be unknown, in which case {@code index} must be less than the - * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + * marked as {@link #AD_STATE_AVAILABLE}. */ @CheckResult public AdGroup withAdUri(Uri uri, int index) { - Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); - Assertions.checkArgument(states[index] == AD_STATE_UNAVAILABLE); long[] durationsUs = this.durationsUs.length == states.length ? this.durationsUs From ed0778d0efe2f5e6d83ad03dfb83290bced6c58c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 14:08:29 +0100 Subject: [PATCH 0488/1052] Workaround unexpected discard of preloaded ad After an ad pod coming up has preloaded, if the user seeks before it plays we get pauseAd/stopAd called for that ad pod. Also, the ad will not load again. Work around this unexpected behavior by handling pauseAd/stopAd and discarding the ad. In future, it's likely that the IMA SDK will stop calling those methods, and will loadAd again for the preloaded ad that was unexpectedly discarded. This change should be compatible with that, because the ad won't be discarded any more due to not calling stopAd. Issue: #7492 PiperOrigin-RevId: 316873699 --- RELEASENOTES.md | 3 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 34 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7b002474ff..0073540f1c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -220,6 +220,9 @@ ([#7508](https://github.com/google/ExoPlayer/issues/7508)). * Fix a bug where the number of ads in an ad group couldn't change ([#7477](https://github.com/google/ExoPlayer/issues/7477)). + * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded + on seeking to another position + ([#7492](https://github.com/google/ExoPlayer/issues/7492)). * Demo app: Retain previous position in list of samples. * Add Guava dependency. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 454f9513e9..a8748219ef 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1478,11 +1478,16 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { @Override public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { try { - if (DEBUG) { - Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } if (adsManager == null) { // Drop events after release. + if (DEBUG) { + Log.d( + TAG, + "loadAd after release " + + getAdMediaInfoString(adMediaInfo) + + ", ad pod " + + adPodInfo); + } return; } @@ -1490,6 +1495,9 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (DEBUG) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo)); + } if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { // We have already marked this ad as having failed to load, so ignore the request. IMA // will timeout after its media load timeout. @@ -1590,10 +1598,21 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { // Drop event after release. return; } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called if loadAd has been called but the preloaded ad won't play due to a + // seek to a different position, so drop the event and discard the ad. See also [Internal: + // b/159111848]. + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (adInfo != null) { + adPlaybackState = + adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup); + updateAdPlaybackState(); + } + return; + } try { Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); stopAdInternal(); } catch (RuntimeException e) { maybeNotifyInternalError("stopAd", e); @@ -1605,8 +1624,13 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { if (DEBUG) { Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); } + if (adsManager == null) { + // Drop event after release. + return; + } if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. + // This method is called if loadAd has been called but the loaded ad won't play due to a + // seek to a different position, so drop the event. See also [Internal: b/159111848]. return; } From 28c5fa665f00bd1d16ee067474df2908b20a2f52 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 5 Jun 2020 18:50:01 +0100 Subject: [PATCH 0489/1052] Improve ImaAdsLoaderTest ad duration handling Previously the fake ads loader listener would always pass the same ad durations to the fake player, but actually the known ad durations can change during playback. Make the fake behavior more realistic by only exposing durations for ads that have loaded. PiperOrigin-RevId: 314956223 --- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 804434b835..fb3a00b20f 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -91,7 +91,6 @@ public final class ImaAdsLoaderTest { private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -144,14 +143,14 @@ public final class ImaAdsLoaderTest { @Test public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test public void start_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); @@ -161,7 +160,7 @@ public final class ImaAdsLoaderTest { @Test public void start_withPlaceholderContent_initializedAdsLoader() { Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); - setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(placeholderTimeline, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); // We'll only create the rendering settings when initializing the ads loader. @@ -170,26 +169,25 @@ public final class ImaAdsLoaderTest { @Test public void start_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ 0) - .withAdDurationsUs(ADS_DURATIONS_US) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void startAfterRelease() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test public void startAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -216,7 +214,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -249,14 +247,14 @@ public final class ImaAdsLoaderTest { .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } @Test public void playback_withPostrollFetchError_marksAdAsInErrorState() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, new Float[] {-1f}); + setupPlayback(CONTENT_TIMELINE, new Float[] {-1f}); // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -266,7 +264,7 @@ public final class ImaAdsLoaderTest { .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) - .withAdDurationsUs(ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } @@ -277,7 +275,6 @@ public final class ImaAdsLoaderTest { long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; setupPlayback( CONTENT_TIMELINE, - ADS_DURATIONS_US, new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); // Advance playback to just before the midroll and simulate buffering. @@ -291,8 +288,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) - .withContentDurationUs(CONTENT_PERIOD_DURATION_US) - .withAdDurationsUs(ADS_DURATIONS_US)); + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test @@ -301,7 +297,6 @@ public final class ImaAdsLoaderTest { long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; setupPlayback( CONTENT_TIMELINE, - ADS_DURATIONS_US, new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); // Advance playback to just before the midroll and simulate buffering. @@ -316,14 +311,14 @@ public final class ImaAdsLoaderTest { .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) - .withAdDurationsUs(ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } @Test public void stop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); @@ -333,9 +328,9 @@ public final class ImaAdsLoaderTest { inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } - private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) { + private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { fakeExoPlayer = new FakePlayer(); - adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs); + adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); imaAdsLoader = new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) @@ -349,7 +344,7 @@ public final class ImaAdsLoaderTest { ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class); doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture()); when(mockAdsRequest.getUserRequestContext()) - .thenAnswer((Answer) invocation -> userRequestContextCaptor.getValue()); + .thenAnswer(invocation -> userRequestContextCaptor.getValue()); List adsLoadedListeners = new ArrayList<>(); doAnswer( @@ -422,19 +417,21 @@ public final class ImaAdsLoaderTest { private final FakePlayer fakeExoPlayer; private final Timeline contentTimeline; - private final long[][] adDurationsUs; public AdPlaybackState adPlaybackState; - public TestAdsLoaderListener( - FakePlayer fakeExoPlayer, Timeline contentTimeline, long[][] adDurationsUs) { + public TestAdsLoaderListener(FakePlayer fakeExoPlayer, Timeline contentTimeline) { this.fakeExoPlayer = fakeExoPlayer; this.contentTimeline = contentTimeline; - this.adDurationsUs = adDurationsUs; } @Override public void onAdPlaybackState(AdPlaybackState adPlaybackState) { + long[][] adDurationsUs = new long[adPlaybackState.adGroupCount][]; + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + adDurationsUs[adGroupIndex] = new long[adPlaybackState.adGroups[adGroupIndex].uris.length]; + Arrays.fill(adDurationsUs[adGroupIndex], TEST_AD_DURATION_US); + } adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); this.adPlaybackState = adPlaybackState; fakeExoPlayer.updateTimeline( From 5d74fced1d261ff63b1868e317262e5e96fda89d Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 9 Jun 2020 08:02:53 +0100 Subject: [PATCH 0490/1052] Add tests for resuming ad playbacks This is in preparation for refactoring the logic to support not playing an ad before the resume position (optionally). PiperOrigin-RevId: 315431483 --- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index fb3a00b20f..3892f29fd4 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -17,10 +17,12 @@ package com.google.android.exoplayer2.ext.ima; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -316,6 +318,134 @@ public final class ImaAdsLoaderTest { .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } + @Test + public void resumePlaybackBeforeMidroll_playsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); From 80f4197e0fc0a673f873a47eaaadfa4c58b181d7 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 9 Jun 2020 16:31:30 +0100 Subject: [PATCH 0491/1052] Separate ads rendering and AdsManager init In a later change it will be necessary to be able to destroy the ads manager if all ads are skipped while creating ads rendering settings. This change prepares for doing that by not having the ads manager passed into the method (so the caller can null or initialize it). PiperOrigin-RevId: 315488830 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 19109d9c04..dfdc4747c7 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -383,7 +383,7 @@ public final class ImaAdsLoader private int lastVolumePercentage; @Nullable private AdsManager adsManager; - private boolean initializedAdsManager; + private boolean isAdsManagerInitialized; private boolean hasAdPlaybackState; @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; @@ -980,9 +980,16 @@ public final class ImaAdsLoader if (contentDurationUs != C.TIME_UNSET) { adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); } - if (!initializedAdsManager && adsManager != null) { - initializedAdsManager = true; - initializeAdsManager(adsManager); + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + adsManager.init(adsRenderingSettings); + adsManager.start(); + updateAdPlaybackState(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } } handleTimelineOrPositionChanged(); } @@ -1047,7 +1054,8 @@ public final class ImaAdsLoader // Internal methods. - private void initializeAdsManager(AdsManager adsManager) { + /** Configures ads rendering for starting playback, returning the settings for the IMA SDK. */ + private AdsRenderingSettings setupAdsRendering() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes(supportedMimeTypes); @@ -1063,36 +1071,32 @@ public final class ImaAdsLoader } // Skip ads based on the start position as required. - long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; long contentPositionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); int adGroupIndexForPosition = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + if (adGroupIndexForPosition != C.INDEX_UNSET) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be cleared. + if (hasMidrollAdGroups(adGroupTimesUs)) { + pendingContentPositionMs = contentPositionMs; + } + if (adGroupIndexForPosition > 0) { + // Skip any ad groups before the one at or immediately before the playback position. + for (int i = 0; i < adGroupIndexForPosition; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + // Play ads after the midpoint between the ad to play and the one before it, to avoid issues + // with rounding one of the two ad times. + long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; + long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); } - // Play ads after the midpoint between the ad to play and the one before it, to avoid issues - // with rounding one of the two ad times. - long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; - long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; - double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; - adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); - } - - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { - // Provide the player's initial position to trigger loading and playing the ad. - pendingContentPositionMs = contentPositionMs; - } - - adsManager.init(adsRenderingSettings); - adsManager.start(); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); } + return adsRenderingSettings; } private void handleAdEvent(AdEvent adEvent) { From e5ec8e6b474cd7e00719fdf986f2ec655bc5a866 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 9 Jun 2020 18:37:00 +0100 Subject: [PATCH 0492/1052] Prevent shutter closing for within-window seeks to unprepared periods Issue: #5507 PiperOrigin-RevId: 315512207 --- RELEASENOTES.md | 7 ++++ .../android/exoplayer2/ui/PlayerView.java | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9055d86943..201ffb9196 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,12 @@ # Release notes # +### 2.11.6 (not yet released) ### + +* UI: Prevent `PlayerView` from temporarily hiding the video surface when + seeking to an unprepared period within the current window. For example when + seeking over an ad group, or to the next period in a multi-period DASH + stream ([#5507](https://github.com/google/ExoPlayer/issues/5507)). + ### 2.11.5 (2020-06-05) ### * Improve the smoothness of video playback immediately after starting, seeking diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java index 2eae9c1dde..dbddf1390e 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java @@ -48,6 +48,8 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.flac.PictureFrame; import com.google.android.exoplayer2.metadata.id3.ApicFrame; @@ -1506,6 +1508,13 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider SingleTapListener, PlayerControlView.VisibilityListener { + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + // TextOutput implementation @Override @@ -1554,6 +1563,29 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider @Override public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + Player player = Assertions.checkNotNull(PlayerView.this.player); + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + lastPeriodUidWithTracks = null; + } else if (!player.getCurrentTrackGroups().isEmpty()) { + lastPeriodUidWithTracks = + timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid; + } else if (lastPeriodUidWithTracks != null) { + int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks); + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + int lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex; + if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) { + // We're in the same window. Suppress the update. + return; + } + } + lastPeriodUidWithTracks = null; + } + updateForCurrentTrackSelections(/* isNewPlayer= */ false); } From e261b8d0ef8e0d35b3f4ec2d936ba08ce8a6d151 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 11 Jun 2020 11:17:43 +0100 Subject: [PATCH 0493/1052] Allow skipping the ad before the start position PiperOrigin-RevId: 315867160 --- RELEASENOTES.md | 1 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 112 ++++++++--- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 182 +++++++++++++++++- 3 files changed, 260 insertions(+), 35 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 201ffb9196..8f7e80a35f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -6,6 +6,7 @@ seeking to an unprepared period within the current window. For example when seeking over an ad group, or to the next period in a multi-period DASH stream ([#5507](https://github.com/google/ExoPlayer/issues/5507)). +* IMA extension: Add option to skip ads before the start position. ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index dfdc4747c7..694a136818 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -123,6 +123,7 @@ public final class ImaAdsLoader private int mediaLoadTimeoutMs; private int mediaBitrate; private boolean focusSkipButtonWhenAvailable; + private boolean playAdBeforeStartPosition; private ImaFactory imaFactory; /** @@ -137,6 +138,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; focusSkipButtonWhenAvailable = true; + playAdBeforeStartPosition = true; imaFactory = new DefaultImaFactory(); } @@ -250,6 +252,21 @@ public final class ImaAdsLoader return this; } + /** + * Sets whether to play an ad before the start position when beginning playback. If {@code + * true}, an ad will be played if there is one at or before the start position. If {@code + * false}, an ad will be played only if there is one exactly at the start position. The default + * setting is {@code true}. + * + * @param playAdBeforeStartPosition Whether to play an ad before the start position when + * beginning playback. + * @return This builder, for convenience. + */ + public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { + this.playAdBeforeStartPosition = playAdBeforeStartPosition; + return this; + } + @VisibleForTesting /* package */ Builder setImaFactory(ImaFactory imaFactory) { this.imaFactory = Assertions.checkNotNull(imaFactory); @@ -275,6 +292,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, adEventListener, imaFactory); @@ -298,6 +316,7 @@ public final class ImaAdsLoader mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, adEventListener, imaFactory); @@ -360,6 +379,7 @@ public final class ImaAdsLoader private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; + private final boolean playAdBeforeStartPosition; private final int mediaBitrate; @Nullable private final Set adUiElements; @Nullable private final AdEventListener adEventListener; @@ -465,6 +485,7 @@ public final class ImaAdsLoader /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, /* focusSkipButtonWhenAvailable= */ true, + /* playAdBeforeStartPosition= */ true, /* adUiElements= */ null, /* adEventListener= */ null, /* imaFactory= */ new DefaultImaFactory()); @@ -481,6 +502,7 @@ public final class ImaAdsLoader int mediaLoadTimeoutMs, int mediaBitrate, boolean focusSkipButtonWhenAvailable, + boolean playAdBeforeStartPosition, @Nullable Set adUiElements, @Nullable AdEventListener adEventListener, ImaFactory imaFactory) { @@ -492,6 +514,7 @@ public final class ImaAdsLoader this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = playAdBeforeStartPosition; this.adUiElements = adUiElements; this.adEventListener = adEventListener; this.imaFactory = imaFactory; @@ -671,15 +694,7 @@ public final class ImaAdsLoader @Override public void release() { pendingAdRequestContext = null; - if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); - if (adEventListener != null) { - adsManager.removeAdEventListener(adEventListener); - } - adsManager.destroy(); - adsManager = null; - } + destroyAdsManager(); adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; @@ -983,13 +998,18 @@ public final class ImaAdsLoader @Nullable AdsManager adsManager = this.adsManager; if (!isAdsManagerInitialized && adsManager != null) { isAdsManagerInitialized = true; - AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); - adsManager.init(adsRenderingSettings); - adsManager.start(); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } } + updateAdPlaybackState(); } handleTimelineOrPositionChanged(); } @@ -1054,7 +1074,11 @@ public final class ImaAdsLoader // Internal methods. - /** Configures ads rendering for starting playback, returning the settings for the IMA SDK. */ + /** + * Configures ads rendering for starting playback, returning the settings for the IMA SDK or + * {@code null} if no ads should play. + */ + @Nullable private AdsRenderingSettings setupAdsRendering() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); @@ -1074,26 +1098,42 @@ public final class ImaAdsLoader long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; long contentPositionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); - int adGroupIndexForPosition = + int adGroupForPositionIndex = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition != C.INDEX_UNSET) { - // Provide the player's initial position to trigger loading and playing the ad. If there are - // no midrolls, we are playing a preroll and any pending content position wouldn't be cleared. - if (hasMidrollAdGroups(adGroupTimesUs)) { + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + playAdBeforeStartPosition + || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs); + if (!playAdWhenStartingPlayback) { + adGroupForPositionIndex++; + } else if (hasMidrollAdGroups(adGroupTimesUs)) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be + // cleared. pendingContentPositionMs = contentPositionMs; } - if (adGroupIndexForPosition > 0) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { + if (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } - // Play ads after the midpoint between the ad to play and the one before it, to avoid issues - // with rounding one of the two ad times. - long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; - long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; - double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; - adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + if (adGroupForPositionIndex == adGroupTimesUs.length) { + // We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP + // ads, we signal that no ads will render so the caller can destroy the ads manager. + return null; + } + long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex]; + long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1]; + if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) { + // Play the postroll by offsetting the start position just past the last non-postroll ad. + adsRenderingSettings.setPlayAdsAfterTime( + (double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d); + } else { + // Play ads after the midpoint between the ad to play and the one before it, to avoid + // issues with rounding one of the two ad times. + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + } } } return adsRenderingSettings; @@ -1543,6 +1583,18 @@ public final class ImaAdsLoader } } + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(this); + adsManager.removeAdEventListener(this); + if (adEventListener != null) { + adsManager.removeAdEventListener(adEventListener); + } + adsManager.destroy(); + adsManager = null; + } + } + /** Factory for objects provided by the IMA SDK. */ @VisibleForTesting /* package */ interface ImaFactory { diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 3892f29fd4..a8317f8cc4 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -446,6 +446,171 @@ public final class ImaAdsLoaderTest { .withSkippedAdGroup(/* adGroupIndex= */ 0)); } + @Test + public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMidroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsManager).destroy(); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withSkippedAdGroup(/* adGroupIndex= */ 1)); + } + + @Test + public void + resumePlaybackBeforeSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + setupPlayback( + CONTENT_TIMELINE, + new Float[] { + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND + }, + new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState( + /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); @@ -459,14 +624,21 @@ public final class ImaAdsLoaderTest { } private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { - fakeExoPlayer = new FakePlayer(); - adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); - when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); - imaAdsLoader = + setupPlayback( + contentTimeline, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI); + .buildForAdTag(TEST_URI)); + } + + private void setupPlayback( + Timeline contentTimeline, Float[] cuePoints, ImaAdsLoader imaAdsLoader) { + fakeExoPlayer = new FakePlayer(); + adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); + when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); } From 8ab3fab4c13f247128ff125fe4c9615f050faf51 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 11:37:50 +0100 Subject: [PATCH 0494/1052] Fix catch type in ImaAdsLoader defensive checks PiperOrigin-RevId: 316079131 --- .../android/exoplayer2/ext/ima/ImaAdsLoader.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 694a136818..466a955967 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -715,7 +715,7 @@ public final class ImaAdsLoader } try { handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("handlePrepareError", e); } } @@ -742,7 +742,7 @@ public final class ImaAdsLoader adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); hasAdPlaybackState = true; updateAdPlaybackState(); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("onAdsManagerLoaded", e); } } @@ -762,7 +762,7 @@ public final class ImaAdsLoader } try { handleAdEvent(adEvent); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("onAdEvent", e); } } @@ -784,7 +784,7 @@ public final class ImaAdsLoader } else if (isAdGroupLoadError(error)) { try { handleAdGroupLoadError(error); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("onAdError", e); } } @@ -885,7 +885,7 @@ public final class ImaAdsLoader adPlaybackState = adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); updateAdPlaybackState(); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("loadAd", e); } } @@ -959,7 +959,7 @@ public final class ImaAdsLoader Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { stopAdInternal(); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("stopAd", e); } } From f1197d8af34f6b88d4b4dd908c7b57273be3c349 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 12:20:43 +0100 Subject: [PATCH 0495/1052] Handle errors in all VideoAdPlayer callbacks Some but not all VideoAdPlayer callbacks from the IMA SDK included defensive handling of unexpected cases. Add the remaining ones. Issue: #7492 PiperOrigin-RevId: 316082651 --- RELEASENOTES.md | 5 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 65 +++++++++++-------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8f7e80a35f..76c95d5379 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -6,7 +6,10 @@ seeking to an unprepared period within the current window. For example when seeking over an ad group, or to the next period in a multi-period DASH stream ([#5507](https://github.com/google/ExoPlayer/issues/5507)). -* IMA extension: Add option to skip ads before the start position. +* IMA extension: + * Add option to skip ads before the start position. + * Catch unexpected errors in `stopAd` to avoid a crash + ([#7492](https://github.com/google/ExoPlayer/issues/7492)). ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 466a955967..3bb8d9f386 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -916,32 +916,36 @@ public final class ImaAdsLoader Log.w(TAG, "Unexpected playAd without stopAd"); } - if (imaAdState == IMA_AD_STATE_NONE) { - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - imaAdMediaInfo = adMediaInfo; - imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(adMediaInfo); - } - if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { - pendingAdPrepareErrorAdInfo = null; + try { + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(adMediaInfo); + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); } } - updateAdProgress(); - } else { - imaAdState = IMA_AD_STATE_PLAYING; - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(adMediaInfo); + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { + Assertions.checkNotNull(adsManager).pause(); } - } - if (!Assertions.checkNotNull(player).getPlayWhenReady()) { - Assertions.checkNotNull(adsManager).pause(); + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); } } @@ -955,9 +959,9 @@ public final class ImaAdsLoader return; } - Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); stopAdInternal(); } catch (RuntimeException e) { maybeNotifyInternalError("stopAd", e); @@ -973,10 +977,15 @@ public final class ImaAdsLoader // This method is called after content is resumed. return; } - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); + + try { + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); } } From 1ce040003a3c385b0279ce6964aca07e881e1f38 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 12 Jun 2020 15:45:10 +0100 Subject: [PATCH 0496/1052] Add AdPlaybackState toString This is useful for debugging both in tests and via logging. PiperOrigin-RevId: 316102968 --- .../source/ads/AdPlaybackState.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 783a452b1a..d56c4eefac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -489,6 +489,54 @@ public final class AdPlaybackState { return result; } + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("AdPlaybackState(adResumePositionUs="); + sb.append(adResumePositionUs); + sb.append(", adGroups=["); + for (int i = 0; i < adGroups.length; i++) { + sb.append("adGroup(timeUs="); + sb.append(adGroupTimesUs[i]); + sb.append(", ads=["); + for (int j = 0; j < adGroups[i].states.length; j++) { + sb.append("ad(state="); + switch (adGroups[i].states[j]) { + case AD_STATE_UNAVAILABLE: + sb.append('_'); + break; + case AD_STATE_ERROR: + sb.append('!'); + break; + case AD_STATE_AVAILABLE: + sb.append('R'); + break; + case AD_STATE_PLAYED: + sb.append('P'); + break; + case AD_STATE_SKIPPED: + sb.append('S'); + break; + default: + sb.append('?'); + break; + } + sb.append(", durationUs="); + sb.append(adGroups[i].durationsUs[j]); + sb.append(')'); + if (j < adGroups[i].states.length - 1) { + sb.append(", "); + } + } + sb.append("])"); + if (i < adGroups.length - 1) { + sb.append(", "); + } + } + sb.append("])"); + return sb.toString(); + } + private boolean isPositionBeforeAdGroup( long positionUs, long periodDurationUs, int adGroupIndex) { if (positionUs == C.TIME_END_OF_SOURCE) { From a0e90ce1fff7dbe681c8e27ca094812799e74e35 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 15 Jun 2020 11:54:43 +0100 Subject: [PATCH 0497/1052] Pull IMA cuePoints -> adGroupTimesUs logic into a helper class We're then able to use this same helper class from tests, to avoid running into spurious failures caused by long microseconds being round-tripped through float seconds. PiperOrigin-RevId: 316435084 --- .../ext/ima/AdPlaybackStateFactory.java | 56 ++++++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 26 +-- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 168 ++++++++---------- 3 files changed, 134 insertions(+), 116 deletions(-) create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java new file mode 100644 index 0000000000..3c1b6954aa --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.ima; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import java.util.Arrays; +import java.util.List; + +/** + * Static utility class for constructing {@link AdPlaybackState} instances from IMA-specific data. + */ +/* package */ final class AdPlaybackStateFactory { + private AdPlaybackStateFactory() {} + + /** + * Construct an {@link AdPlaybackState} from the provided {@code cuePoints}. + * + * @param cuePoints The cue points of the ads in seconds. + * @return The {@link AdPlaybackState}. + */ + public static AdPlaybackState fromCuePoints(List cuePoints) { + if (cuePoints.isEmpty()) { + // If no cue points are specified, there is a preroll ad. + return new AdPlaybackState(/* adGroupTimesUs...= */ 0); + } + + int count = cuePoints.size(); + long[] adGroupTimesUs = new long[count]; + int adGroupIndex = 0; + for (int i = 0; i < count; i++) { + double cuePoint = cuePoints.get(i); + if (cuePoint == -1.0) { + adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; + } else { + adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); + } + } + // Cue points may be out of order, so sort them. + Arrays.sort(adGroupTimesUs, 0, adGroupIndex); + return new AdPlaybackState(adGroupTimesUs); + } +} diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 3bb8d9f386..f4d78893a9 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -662,7 +662,7 @@ public final class ImaAdsLoader adsManager.resume(); } } else if (adsManager != null) { - adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); updateAdPlaybackState(); } else { // Ads haven't loaded yet, so request them. @@ -739,7 +739,7 @@ public final class ImaAdsLoader if (player != null) { // If a player is attached already, start playback immediately. try { - adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); hasAdPlaybackState = true; updateAdPlaybackState(); } catch (RuntimeException e) { @@ -1545,28 +1545,6 @@ public final class ImaAdsLoader : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } - private static long[] getAdGroupTimesUs(List cuePoints) { - if (cuePoints.isEmpty()) { - // If no cue points are specified, there is a preroll ad. - return new long[] {0}; - } - - int count = cuePoints.size(); - long[] adGroupTimesUs = new long[count]; - int adGroupIndex = 0; - for (int i = 0; i < count; i++) { - double cuePoint = cuePoints.get(i); - if (cuePoint == -1.0) { - adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; - } else { - adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); - } - } - // Cue points may be out of order, so sort them. - Arrays.sort(adGroupTimesUs, 0, adGroupIndex); - return adGroupTimesUs; - } - private static boolean isAdGroupLoadError(AdError adError) { // TODO: Find out what other errors need to be handled (if any), and whether each one relates to // a single ad, ad group or the whole timeline. diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index a8317f8cc4..88e712f6ea 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -58,6 +58,7 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.time.Duration; @@ -93,7 +94,7 @@ public final class ImaAdsLoaderTest { private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; + private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f); @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -256,7 +257,7 @@ public final class ImaAdsLoaderTest { @Test public void playback_withPostrollFetchError_marksAdAsInErrorState() { - setupPlayback(CONTENT_TIMELINE, new Float[] {-1f}); + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(-1f)); // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -275,9 +276,9 @@ public final class ImaAdsLoaderTest { public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { // Simulate an ad at 2 seconds. long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; - setupPlayback( - CONTENT_TIMELINE, - new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + long adGroupTimeUs = adGroupPositionInWindowUs; + ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); // Advance playback to just before the midroll and simulate buffering. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -289,7 +290,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -297,9 +298,9 @@ public final class ImaAdsLoaderTest { public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { // Simulate an ad at 2 seconds. long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; - setupPlayback( - CONTENT_TIMELINE, - new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + long adGroupTimeUs = adGroupPositionInWindowUs; + ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); // Advance playback to just before the midroll and simulate buffering. imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -311,7 +312,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -321,10 +322,10 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackBeforeMidroll_playsPreroll() { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long midrollPeriodTimeUs = - midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + long midrollPeriodTimeUs = midrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -332,17 +333,17 @@ public final class ImaAdsLoaderTest { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void resumePlaybackAtMidroll_skipsPreroll() { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long midrollPeriodTimeUs = - midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + long midrollPeriodTimeUs = midrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -355,7 +356,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -363,10 +364,10 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackAfterMidroll_skipsPreroll() { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long midrollPeriodTimeUs = - midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}); + long midrollPeriodTimeUs = midrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -379,7 +380,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -387,19 +388,14 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long firstMidrollPeriodTimeUs = - firstMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long firstMidrollPeriodTimeUs = firstMidrollWindowTimeUs; long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; - long secondMidrollPeriodTimeUs = - secondMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }); + long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -407,27 +403,21 @@ public final class ImaAdsLoaderTest { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long firstMidrollPeriodTimeUs = - firstMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long firstMidrollPeriodTimeUs = firstMidrollWindowTimeUs; long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; - long secondMidrollPeriodTimeUs = - secondMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; - setupPlayback( - CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }); + long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -440,8 +430,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -449,11 +438,12 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long midrollPeriodTimeUs = - midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long midrollPeriodTimeUs = midrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -471,7 +461,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -479,11 +469,12 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long midrollPeriodTimeUs = - midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long midrollPeriodTimeUs = midrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -501,7 +492,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -509,11 +500,12 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMidroll() { long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long midrollPeriodTimeUs = - midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long midrollPeriodTimeUs = midrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] {0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND}, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -526,7 +518,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsManager).destroy(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0, midrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withSkippedAdGroup(/* adGroupIndex= */ 1)); @@ -536,19 +528,16 @@ public final class ImaAdsLoaderTest { public void resumePlaybackBeforeSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long firstMidrollPeriodTimeUs = - firstMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long firstMidrollPeriodTimeUs = firstMidrollWindowTimeUs; long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; - long secondMidrollPeriodTimeUs = - secondMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -566,8 +555,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -575,19 +563,16 @@ public final class ImaAdsLoaderTest { @Test public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; - long firstMidrollPeriodTimeUs = - firstMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long firstMidrollPeriodTimeUs = firstMidrollWindowTimeUs; long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; - long secondMidrollPeriodTimeUs = - secondMidrollWindowTimeUs - + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollPeriodTimeUs = secondMidrollWindowTimeUs; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); setupPlayback( CONTENT_TIMELINE, - new Float[] { - (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, - (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND - }, + cuePoints, new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) @@ -605,8 +590,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState( - /* adGroupTimesUs...= */ firstMidrollPeriodTimeUs, secondMidrollPeriodTimeUs) + AdPlaybackStateFactory.fromCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -623,7 +607,7 @@ public final class ImaAdsLoaderTest { inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } - private void setupPlayback(Timeline contentTimeline, Float[] cuePoints) { + private void setupPlayback(Timeline contentTimeline, List cuePoints) { setupPlayback( contentTimeline, cuePoints, @@ -634,10 +618,10 @@ public final class ImaAdsLoaderTest { } private void setupPlayback( - Timeline contentTimeline, Float[] cuePoints, ImaAdsLoader imaAdsLoader) { + Timeline contentTimeline, List cuePoints, ImaAdsLoader imaAdsLoader) { fakeExoPlayer = new FakePlayer(); adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); - when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + when(mockAdsManager.getAdCuePoints()).thenReturn(cuePoints); this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); } From 6e1c1973091cd59ae5536372699cd3b4b4f17ed3 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 16 Jun 2020 18:58:04 +0100 Subject: [PATCH 0498/1052] Add MIME types for which every sample is known to be a sync sample. - Leaving the TODO, since there are still MIME types we're unsure about. - Removing AAC because xHE-AAC does not have this property. We may re-add it with an additional profile check to exclude xHE-AAC in the future. PiperOrigin-RevId: 316715147 --- .../google/android/exoplayer2/util/MimeTypes.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index c3ca9257d6..5465da7489 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -155,12 +155,22 @@ public final class MimeTypes { if (mimeType == null) { return false; } - // TODO: Consider adding additional audio MIME types here. + // TODO: Add additional audio MIME types. Also consider evaluating based on Format rather than + // just MIME type, since in some cases the property is true for a subset of the profiles + // belonging to a single MIME type. If we do this, we should move the method to a different + // class. See [Internal ref: http://go/exo-audio-format-random-access]. switch (mimeType) { - case AUDIO_AAC: case AUDIO_MPEG: case AUDIO_MPEG_L1: case AUDIO_MPEG_L2: + case AUDIO_RAW: + case AUDIO_ALAW: + case AUDIO_MLAW: + case AUDIO_OPUS: + case AUDIO_FLAC: + case AUDIO_AC3: + case AUDIO_E_AC3: + case AUDIO_E_AC3_JOC: return true; default: return false; From 5fd287b340ba2c1e81c234831e6bda9347c0f644 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 07:57:27 +0100 Subject: [PATCH 0499/1052] Move IMA SDK callbacks into inner class The release() method was added in the recent IMA API changes for preloading and now 'collides' with the ExoPlayer AdsLoader release method. This led to all ads completing being treated as a call to completely release the ads loader, which meant that the ad playback state was not updated on resuming after all ads had completed, which in turn led to playback getting stuck buffering on returning from the background after all ads played. Move the IMA callbacks into an inner class to avoid this. Issue: #7508 PiperOrigin-RevId: 316834561 --- RELEASENOTES.md | 3 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 615 +++++++++--------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 82 ++- 3 files changed, 373 insertions(+), 327 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 76c95d5379..9f7114f661 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -10,6 +10,9 @@ * Add option to skip ads before the start position. * Catch unexpected errors in `stopAd` to avoid a crash ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix a bug that caused playback to be stuck buffering on resuming from + the background after all ads had played to the end + ([#7508](https://github.com/google/ExoPlayer/issues/7508)). ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index f4d78893a9..ac19f7e779 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -86,14 +86,7 @@ import java.util.Set; * For more details see {@link AdDisplayContainer#registerVideoControlsOverlay(View)} and {@link * AdViewProvider#getAdOverlayViews()}. */ -public final class ImaAdsLoader - implements Player.EventListener, - AdsLoader, - VideoAdPlayer, - ContentProgressProvider, - AdErrorListener, - AdsLoadedListener, - AdEventListener { +public final class ImaAdsLoader implements Player.EventListener, AdsLoader { static { ExoPlayerLibraryInfo.registerModule("goog.exo.ima"); @@ -364,12 +357,13 @@ public final class ImaAdsLoader */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link - * #pauseAd(AdMediaInfo)}. + * The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not + * {@link ComponentListener##pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. + * The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while + * playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; @@ -386,7 +380,8 @@ public final class ImaAdsLoader private final ImaFactory imaFactory; private final Timeline.Period period; private final Handler handler; - private final List adCallbacks; + private final ComponentListener componentListener; + private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; private final Runnable updateAdProgressRunnable; @@ -400,7 +395,7 @@ public final class ImaAdsLoader @Nullable private Player player; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; - private int lastVolumePercentage; + private int lastVolumePercent; @Nullable private AdsManager adsManager; private boolean isAdsManagerInitialized; @@ -443,10 +438,10 @@ public final class ImaAdsLoader */ @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd(AdMediaInfo)}, - * stores the value of {@link SystemClock#elapsedRealtime()} when the content stopped playing. - * This can be used to determine a fake, increasing content position. {@link C#TIME_UNSET} - * otherwise. + * If a content period has finished but IMA has not yet called {@link + * ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link + * SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine + * a fake, increasing content position. {@link C#TIME_UNSET} otherwise. */ private long fakeContentProgressElapsedRealtimeMs; /** @@ -456,7 +451,10 @@ public final class ImaAdsLoader private long fakeContentProgressOffsetMs; /** Stores the pending content position when a seek operation was intercepted to play an ad. */ private long pendingContentPositionMs; - /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ + /** + * Whether {@link ComponentListener#getContentProgress()} has sent {@link + * #pendingContentPositionMs} to IMA. + */ private boolean sentPendingContentPositionMs; /** * Stores the real time in milliseconds at which the player started buffering, possibly due to not @@ -528,14 +526,15 @@ public final class ImaAdsLoader imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); handler = Util.createHandler(getImaLooper(), /* callback= */ null); + componentListener = new ComponentListener(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); - adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); + adDisplayContainer.setPlayer(/* videoAdPlayer= */ componentListener); adsLoader = imaFactory.createAdsLoader( context.getApplicationContext(), imaSdkSettings, adDisplayContainer); - adsLoader.addAdErrorListener(/* adErrorListener= */ this); - adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + adsLoader.addAdErrorListener(componentListener); + adsLoader.addAdsLoadedListener(componentListener); updateAdProgressRunnable = this::updateAdProgress; adInfoByAdMediaInfo = new HashMap<>(); supportedMimeTypes = Collections.emptyList(); @@ -596,7 +595,7 @@ public final class ImaAdsLoader if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } - request.setContentProgressProvider(this); + request.setContentProgressProvider(componentListener); pendingAdRequestContext = new Object(); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); @@ -645,7 +644,7 @@ public final class ImaAdsLoader player.addListener(this); boolean playWhenReady = player.getPlayWhenReady(); this.eventListener = eventListener; - lastVolumePercentage = 0; + lastVolumePercent = 0; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); @@ -682,9 +681,9 @@ public final class ImaAdsLoader adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); } - lastVolumePercentage = getVolume(); + lastVolumePercent = getPlayerVolumePercent(); lastAdProgress = getAdVideoProgressUpdate(); - lastContentProgress = getContentProgress(); + lastContentProgress = getContentVideoProgressUpdate(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); this.player = null; @@ -695,8 +694,8 @@ public final class ImaAdsLoader public void release() { pendingAdRequestContext = null; destroyAdsManager(); - adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); - adsLoader.removeAdErrorListener(/* adErrorListener= */ this); + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; @@ -704,7 +703,7 @@ public final class ImaAdsLoader imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = false; + hasAdPlaybackState = true; updateAdPlaybackState(); } @@ -720,275 +719,6 @@ public final class ImaAdsLoader } } - // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. - - @Override - public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { - AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); - if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { - adsManager.destroy(); - return; - } - pendingAdRequestContext = null; - this.adsManager = adsManager; - adsManager.addAdErrorListener(this); - adsManager.addAdEventListener(this); - if (adEventListener != null) { - adsManager.addAdEventListener(adEventListener); - } - if (player != null) { - // If a player is attached already, start playback immediately. - try { - adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); - hasAdPlaybackState = true; - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdsManagerLoaded", e); - } - } - } - - // AdEvent.AdEventListener implementation. - - @Override - public void onAdEvent(AdEvent adEvent) { - AdEventType adEventType = adEvent.getType(); - if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { - Log.d(TAG, "onAdEvent: " + adEventType); - } - if (adsManager == null) { - // Drop events after release. - return; - } - try { - handleAdEvent(adEvent); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdEvent", e); - } - } - - // AdErrorEvent.AdErrorListener implementation. - - @Override - public void onAdError(AdErrorEvent adErrorEvent) { - AdError error = adErrorEvent.getError(); - if (DEBUG) { - Log.d(TAG, "onAdError", error); - } - if (adsManager == null) { - // No ads were loaded, so allow playback to start without any ads. - pendingAdRequestContext = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; - updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { - try { - handleAdGroupLoadError(error); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdError", e); - } - } - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAllAds(error); - } - maybeNotifyPendingAdLoadError(); - } - - // ContentProgressProvider implementation. - - @Override - public VideoProgressUpdate getContentProgress() { - VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); - if (DEBUG) { - Log.d(TAG, "Content progress: " + videoProgressUpdate); - } - - if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { - // IMA is polling the player position but we are buffering for an ad to preload, so playback - // may be stuck. Detect this case and signal an error if applicable. - long stuckElapsedRealtimeMs = - SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; - if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { - waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; - handleAdGroupLoadError(new IOException("Ad preloading timed out")); - maybeNotifyPendingAdLoadError(); - } - } - - return videoProgressUpdate; - } - - // VideoAdPlayer implementation. - - @Override - public VideoProgressUpdate getAdProgress() { - throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); - } - - @Override - public int getVolume() { - @Nullable Player player = this.player; - if (player == null) { - return lastVolumePercentage; - } - - @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); - if (audioComponent != null) { - return (int) (audioComponent.getVolume() * 100); - } - - // Check for a selected track using an audio renderer. - TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); - for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { - if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { - return 100; - } - } - return 0; - } - - @Override - public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { - try { - if (DEBUG) { - Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } - if (adsManager == null) { - // Drop events after release. - return; - } - int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); - int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; - AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - adInfoByAdMediaInfo.put(adMediaInfo, adInfo); - if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { - // We have already marked this ad as having failed to load, so ignore the request. IMA will - // timeout after its media load timeout. - return; - } - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - } - for (int i = 0; i < adIndexInAdGroup; i++) { - // Any preceding ads that haven't loaded are not going to load. - if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { - adPlaybackState = - adPlaybackState.withAdLoadError( - /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); - } - } - Uri adUri = Uri.parse(adMediaInfo.getUrl()); - adPlaybackState = - adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("loadAd", e); - } - } - - @Override - public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.add(videoAdPlayerCallback); - } - - @Override - public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.remove(videoAdPlayerCallback); - } - - @Override - public void playAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop events after release. - return; - } - - if (imaAdState == IMA_AD_STATE_PLAYING) { - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028]. - Log.w(TAG, "Unexpected playAd without stopAd"); - } - - try { - if (imaAdState == IMA_AD_STATE_NONE) { - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - imaAdMediaInfo = adMediaInfo; - imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(adMediaInfo); - } - if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { - pendingAdPrepareErrorAdInfo = null; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(adMediaInfo); - } - } - updateAdProgress(); - } else { - imaAdState = IMA_AD_STATE_PLAYING; - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(adMediaInfo); - } - } - if (!Assertions.checkNotNull(player).getPlayWhenReady()) { - Assertions.checkNotNull(adsManager).pause(); - } - } catch (RuntimeException e) { - maybeNotifyInternalError("playAd", e); - } - } - - @Override - public void stopAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop event after release. - return; - } - - try { - Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); - stopAdInternal(); - } catch (RuntimeException e) { - maybeNotifyInternalError("stopAd", e); - } - } - - @Override - public void pauseAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); - } - if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. - return; - } - - try { - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); - } - } catch (RuntimeException e) { - maybeNotifyInternalError("pauseAd", e); - } - } - // Player.EventListener implementation. @Override @@ -1256,6 +986,27 @@ public final class ImaAdsLoader handler.removeCallbacks(updateAdProgressRunnable); } + private int getPlayerVolumePercent() { + @Nullable Player player = this.player; + if (player == null) { + return lastVolumePercent; + } + + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + if (audioComponent != null) { + return (int) (audioComponent.getVolume() * 100); + } + + // Check for a selected track using an audio renderer. + TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); + for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { + return 100; + } + } + return 0; + } + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { @@ -1572,8 +1323,8 @@ public final class ImaAdsLoader private void destroyAdsManager() { if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); + adsManager.removeAdErrorListener(componentListener); + adsManager.removeAdEventListener(componentListener); if (adEventListener != null) { adsManager.removeAdEventListener(adEventListener); } @@ -1598,6 +1349,272 @@ public final class ImaAdsLoader Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } + private final class ComponentListener + implements VideoAdPlayer, + ContentProgressProvider, + AdErrorListener, + AdsLoadedListener, + AdEventListener { + + // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); + if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { + adsManager.destroy(); + return; + } + pendingAdRequestContext = null; + ImaAdsLoader.this.adsManager = adsManager; + adsManager.addAdErrorListener(this); + adsManager.addAdEventListener(this); + if (adEventListener != null) { + adsManager.addAdEventListener(adEventListener); + } + if (player != null) { + // If a player is attached already, start playback immediately. + try { + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); + hasAdPlaybackState = true; + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdsManagerLoaded", e); + } + } + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + AdEventType adEventType = adEvent.getType(); + if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { + Log.d(TAG, "onAdEvent: " + adEventType); + } + if (adsManager == null) { + // Drop events after release. + return; + } + try { + handleAdEvent(adEvent); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdEvent", e); + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + if (DEBUG) { + Log.d(TAG, "onAdError", error); + } + if (adsManager == null) { + // No ads were loaded, so allow playback to start without any ads. + pendingAdRequestContext = null; + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; + updateAdPlaybackState(); + } else if (isAdGroupLoadError(error)) { + try { + handleAdGroupLoadError(error); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdError", e); + } + } + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAllAds(error); + } + maybeNotifyPendingAdLoadError(); + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (DEBUG) { + Log.d(TAG, "Content progress: " + videoProgressUpdate); + } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } + + return videoProgressUpdate; + } + + // VideoAdPlayer implementation. + + @Override + public VideoProgressUpdate getAdProgress() { + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); + } + + @Override + public int getVolume() { + return getPlayerVolumePercent(); + } + + @Override + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + try { + if (DEBUG) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); + } + if (adsManager == null) { + // Drop events after release. + return; + } + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA + // will timeout after its media load timeout. + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + } + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = + adPlaybackState.withAdLoadError( + /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); + adPlaybackState = + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("loadAd", e); + } + } + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @Override + public void playAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop events after release. + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); + } + + try { + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null + && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { + Assertions.checkNotNull(adsManager).pause(); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); + } + } + + @Override + public void stopAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + + try { + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); + stopAdInternal(); + } catch (RuntimeException e) { + maybeNotifyInternalError("stopAd", e); + } + } + + @Override + public void pauseAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called after content is resumed. + return; + } + + try { + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); + } + } + + @Override + public void release() { + // Do nothing. + } + } + // TODO: Consider moving this into AdPlaybackState. private static final class AdInfo { public final int adGroupIndex; diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 88e712f6ea..fce0e34300 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -44,6 +44,8 @@ import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; +import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Player; @@ -113,6 +115,9 @@ public final class ImaAdsLoaderTest { private ViewGroup adViewGroup; private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; + private AdEvent.AdEventListener adEventListener; + private ContentProgressProvider contentProgressProvider; + private VideoAdPlayer videoAdPlayer; private TestAdsLoaderListener adsLoaderListener; private FakePlayer fakeExoPlayer; private ImaAdsLoader imaAdsLoader; @@ -191,6 +196,8 @@ public final class ImaAdsLoaderTest { @Test public void startAndCallbacksAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); + // Request ads in order to get a reference to the ad event listener. + imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -201,16 +208,16 @@ public final class ImaAdsLoaderTest { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + videoAdPlayer.pauseAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); imaAdsLoader.handlePrepareError( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); } @@ -221,27 +228,27 @@ public final class ImaAdsLoaderTest { // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* position= */ 0, /* contentPosition= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) @@ -261,7 +268,7 @@ public final class ImaAdsLoaderTest { // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(mockPostrollFetchErrorAdEvent); + adEventListener.onAdEvent(mockPostrollFetchErrorAdEvent); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -286,7 +293,7 @@ public final class ImaAdsLoaderTest { fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance before the timeout and simulating polling content progress. ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); - imaAdsLoader.getContentProgress(); + contentProgressProvider.getContentProgress(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -308,7 +315,7 @@ public final class ImaAdsLoaderTest { fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance past the timeout and simulate polling content progress. ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); - imaAdsLoader.getContentProgress(); + contentProgressProvider.getContentProgress(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -633,6 +640,8 @@ public final class ImaAdsLoaderTest { .thenAnswer(invocation -> userRequestContextCaptor.getValue()); List adsLoadedListeners = new ArrayList<>(); + // Deliberately don't handle removeAdsLoadedListener to allow testing behavior if the IMA SDK + // invokes callbacks after release. doAnswer( invocation -> { adsLoadedListeners.add(invocation.getArgument(0)); @@ -640,13 +649,6 @@ public final class ImaAdsLoaderTest { }) .when(mockAdsLoader) .addAdsLoadedListener(any()); - doAnswer( - invocation -> { - adsLoadedListeners.remove(invocation.getArgument(0)); - return null; - }) - .when(mockAdsLoader) - .removeAdsLoadedListener(any()); when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); when(mockAdsManagerLoadedEvent.getUserRequestContext()) .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); @@ -662,6 +664,30 @@ public final class ImaAdsLoaderTest { .when(mockAdsLoader) .requestAds(mockAdsRequest); + doAnswer( + invocation -> { + adEventListener = invocation.getArgument(0); + return null; + }) + .when(mockAdsManager) + .addAdEventListener(any()); + + doAnswer( + invocation -> { + contentProgressProvider = invocation.getArgument(0); + return null; + }) + .when(mockAdsRequest) + .setContentProgressProvider(any()); + + doAnswer( + invocation -> { + videoAdPlayer = invocation.getArgument(0); + return null; + }) + .when(mockAdDisplayContainer) + .setPlayer(any()); + when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer); when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings); when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); From fc76dbfad4d2264ca4136c5545f5e82615ddfceb Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 13:46:22 +0100 Subject: [PATCH 0500/1052] Remove some ad playback state change requirements Ads can appear due to asynchronous ad tag requests completing after earlier ads in a pod have loaded, so remove the requirement that the ad count can't change. The MediaPeriodQueue should handling discarding buffered content if an ad appears before already buffered content, so I think this case is actually handled correctly by the core player already. Also remove the requirement that an ad URI can't change. This is a defensive measure for now, but it's likely that a later fix in the IMA SDK for an issue where loadAd is not called after preloading then seeking before a preloaded ad plays will result in loadAd being called more than once, and I think it's possible that the second call to loadAd may have a different URI. Because the ad URI should only change after an intermediate seek to another MediaPeriod, there shouldn't be any problems with buffered data not getting discarded. Issue: #7477 PiperOrigin-RevId: 316871371 --- RELEASENOTES.md | 2 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 19 +++++++++++-------- .../source/ads/AdPlaybackState.java | 14 ++------------ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9f7114f661..51d046c6d1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -13,6 +13,8 @@ * Fix a bug that caused playback to be stuck buffering on resuming from the background after all ads had played to the end ([#7508](https://github.com/google/ExoPlayer/issues/7508)). + * Fix a bug where the number of ads in an ad group couldn't change + ([#7477](https://github.com/google/ExoPlayer/issues/7477)). ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index ac19f7e779..eb0a2877ac 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1476,6 +1476,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { // Drop events after release. return; } + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); @@ -1485,21 +1486,23 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { // will timeout after its media load timeout. return; } + + // The ad count may increase on successive loads of ads in the same ad pod, for example, due + // to separate requests for ad tags with multiple ads within the ad pod completing after an + // earlier ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477. AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - } + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; for (int i = 0; i < adIndexInAdGroup; i++) { // Any preceding ads that haven't loaded are not going to load. if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { adPlaybackState = - adPlaybackState.withAdLoadError( - /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i); } } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); adPlaybackState = adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index d56c4eefac..70128c78bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -124,13 +124,9 @@ public final class AdPlaybackState { return result; } - /** - * Returns a new instance with the ad count set to {@code count}. This method may only be called - * if this instance's ad count has not yet been specified. - */ + /** Returns a new instance with the ad count set to {@code count}. */ @CheckResult public AdGroup withAdCount(int count) { - Assertions.checkArgument(this.count == C.LENGTH_UNSET && states.length <= count); @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); @NullableType Uri[] uris = Arrays.copyOf(this.uris, count); @@ -139,17 +135,11 @@ public final class AdPlaybackState { /** * Returns a new instance with the specified {@code uri} set for the specified ad, and the ad - * marked as {@link #AD_STATE_AVAILABLE}. The specified ad must currently be in {@link - * #AD_STATE_UNAVAILABLE}, which is the default state. - * - *

        This instance's ad count may be unknown, in which case {@code index} must be less than the - * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + * marked as {@link #AD_STATE_AVAILABLE}. */ @CheckResult public AdGroup withAdUri(Uri uri, int index) { - Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); - Assertions.checkArgument(states[index] == AD_STATE_UNAVAILABLE); long[] durationsUs = this.durationsUs.length == states.length ? this.durationsUs From 49951d4f8755245a8c8f8914cdc440ac7e9663be Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 14:08:29 +0100 Subject: [PATCH 0501/1052] Workaround unexpected discard of preloaded ad After an ad pod coming up has preloaded, if the user seeks before it plays we get pauseAd/stopAd called for that ad pod. Also, the ad will not load again. Work around this unexpected behavior by handling pauseAd/stopAd and discarding the ad. In future, it's likely that the IMA SDK will stop calling those methods, and will loadAd again for the preloaded ad that was unexpectedly discarded. This change should be compatible with that, because the ad won't be discarded any more due to not calling stopAd. Issue: #7492 PiperOrigin-RevId: 316873699 --- RELEASENOTES.md | 3 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 34 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 51d046c6d1..4b1d0446f0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -15,6 +15,9 @@ ([#7508](https://github.com/google/ExoPlayer/issues/7508)). * Fix a bug where the number of ads in an ad group couldn't change ([#7477](https://github.com/google/ExoPlayer/issues/7477)). + * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded + on seeking to another position + ([#7492](https://github.com/google/ExoPlayer/issues/7492)). ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index eb0a2877ac..37f49e317b 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1469,11 +1469,16 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { @Override public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { try { - if (DEBUG) { - Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } if (adsManager == null) { // Drop events after release. + if (DEBUG) { + Log.d( + TAG, + "loadAd after release " + + getAdMediaInfoString(adMediaInfo) + + ", ad pod " + + adPodInfo); + } return; } @@ -1481,6 +1486,9 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (DEBUG) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo)); + } if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { // We have already marked this ad as having failed to load, so ignore the request. IMA // will timeout after its media load timeout. @@ -1581,10 +1589,21 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { // Drop event after release. return; } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called if loadAd has been called but the preloaded ad won't play due to a + // seek to a different position, so drop the event and discard the ad. See also [Internal: + // b/159111848]. + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (adInfo != null) { + adPlaybackState = + adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup); + updateAdPlaybackState(); + } + return; + } try { Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); stopAdInternal(); } catch (RuntimeException e) { maybeNotifyInternalError("stopAd", e); @@ -1596,8 +1615,13 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { if (DEBUG) { Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); } + if (adsManager == null) { + // Drop event after release. + return; + } if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. + // This method is called if loadAd has been called but the loaded ad won't play due to a + // seek to a different position, so drop the event. See also [Internal: b/159111848]. return; } From 99c03052784589cc8b25ce11e74a8b76a04b4fb6 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 17 Jun 2020 20:51:59 +0100 Subject: [PATCH 0502/1052] Fix release notes --- RELEASENOTES.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4b1d0446f0..905b588854 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -35,10 +35,10 @@ ([#7306](https://github.com/google/ExoPlayer/issues/7306)). * Fix issue in `AudioTrackPositionTracker` that could cause negative positions to be reported at the start of playback and immediately after seeking - ([#7456](https://github.com/google/ExoPlayer/issues/7456). + ([#7456](https://github.com/google/ExoPlayer/issues/7456)). * Fix further cases where downloads would sometimes not resume after their network requirements are met - ([#7453](https://github.com/google/ExoPlayer/issues/7453). + ([#7453](https://github.com/google/ExoPlayer/issues/7453)). * DASH: * Merge trick play adaptation sets (i.e., adaptation sets marked with `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as @@ -118,10 +118,10 @@ `DefaultAudioSink` constructor ([#7134](https://github.com/google/ExoPlayer/issues/7134)). * Workaround issue that could cause slower than realtime playback of AAC on - Android 10 ([#6671](https://github.com/google/ExoPlayer/issues/6671). + Android 10 ([#6671](https://github.com/google/ExoPlayer/issues/6671)). * Fix case where another app spuriously holding transient audio focus could prevent ExoPlayer from acquiring audio focus for an indefinite period of - time ([#7182](https://github.com/google/ExoPlayer/issues/7182). + time ([#7182](https://github.com/google/ExoPlayer/issues/7182)). * Fix case where the player volume could be permanently ducked if audio focus was released whilst ducking. * Fix playback of WAV files with trailing non-media bytes @@ -1041,7 +1041,7 @@ ([#4492](https://github.com/google/ExoPlayer/issues/4492) and [#4634](https://github.com/google/ExoPlayer/issues/4634)). * Fix issue where removing looping media from a playlist throws an exception - ([#4871](https://github.com/google/ExoPlayer/issues/4871). + ([#4871](https://github.com/google/ExoPlayer/issues/4871)). * Fix issue where the preferred audio or text track would not be selected if mapped onto a secondary renderer of the corresponding type ([#4711](http://github.com/google/ExoPlayer/issues/4711)). @@ -1458,7 +1458,7 @@ resources when the playback thread has quit by the time the loading task has completed. * ID3: Better handle malformed ID3 data - ([#3792](https://github.com/google/ExoPlayer/issues/3792). + ([#3792](https://github.com/google/ExoPlayer/issues/3792)). * Support 14-bit mode and little endianness in DTS PES packets ([#3340](https://github.com/google/ExoPlayer/issues/3340)). * Demo app: Add ability to download not DRM protected content. From b6f5a263f725089c026bb8416ade555f4f16a2bc Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 17 Jun 2020 17:02:46 +0100 Subject: [PATCH 0503/1052] Rollforward of commit 5612ac50a332e425dc130c3c13a139b9e6fce9ec. *** Reason for rollforward *** Rollforward after making sure the handler is created from the playback thread and not from an app thread. *** Original change description *** Rollback of https://github.com/google/ExoPlayer/commit/e1beb1d1946bb8ca94f62578aee8cbadd97b6e2b *** Original commit *** Expose experimental offload scheduling Add a new scheduling mode that stops ExoPlayer main loop when the audio offload buffer is full and resume it when it has been partially played. This mode needs to be enabled and dissabled manually by the app for now. #exo-offload *** *** PiperOrigin-RevId: 316898804 --- RELEASENOTES.md | 1 + .../exoplayer2/DefaultRenderersFactory.java | 17 +++-- .../google/android/exoplayer2/ExoPlayer.java | 37 +++++++++++ .../android/exoplayer2/ExoPlayerImpl.java | 5 ++ .../exoplayer2/ExoPlayerImplInternal.java | 47 +++++++++++++- .../google/android/exoplayer2/Renderer.java | 32 +++++++++ .../android/exoplayer2/SimpleExoPlayer.java | 5 ++ .../android/exoplayer2/audio/AudioSink.java | 11 ++++ .../audio/AudioTrackPositionTracker.java | 5 ++ .../exoplayer2/audio/DefaultAudioSink.java | 65 ++++++++++++++++++- .../audio/MediaCodecAudioRenderer.java | 19 ++++++ .../exoplayer2/testutil/StubExoPlayer.java | 5 ++ 12 files changed, 242 insertions(+), 7 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0073540f1c..196074485a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -164,6 +164,7 @@ * No longer use a `MediaCodec` in audio passthrough mode. * Check `DefaultAudioSink` supports passthrough, in addition to checking the `AudioCapabilities` + * Add an experimental scheduling mode to save power in offload. ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Adjust input timestamps in `MediaCodecRenderer` to account for the Codec2 MP3 decoder having lower timestamps on the output side. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index bd56974b32..3913922c3c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -219,12 +219,20 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Sets whether audio should be played using the offload path. Audio offload disables audio - * processors (for example speed adjustment). + * Sets whether audio should be played using the offload path. + * + *

        Audio offload disables ExoPlayer audio processing, but significantly reduces the energy + * consumption of the playback when {@link + * ExoPlayer#experimental_enableOffloadScheduling(boolean)} is enabled. + * + *

        Most Android devices can only support one offload {@link android.media.AudioTrack} at a time + * and can invalidate it at any time. Thus an app can never be guaranteed that it will be able to + * play in offload. * *

        The default value is {@code false}. * - * @param enableOffload If audio offload should be used. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. * @return This factory, for convenience. */ public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { @@ -423,7 +431,8 @@ public class DefaultRenderersFactory implements RenderersFactory { * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param enableOffload If the renderer should use audio offload for all supported formats. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b4cd9a399d..b3b369b68e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -20,6 +20,8 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.audio.AudioCapabilities; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; @@ -597,4 +599,39 @@ public interface ExoPlayer extends Player { * @see #setPauseAtEndOfMediaItems(boolean) */ boolean getPauseAtEndOfMediaItems(); + + /** + * Enables audio offload scheduling, which runs ExoPlayer's main loop as rarely as possible when + * playing an audio stream using audio offload. + * + *

        Only use this scheduling mode if the player is not displaying anything to the user. For + * example when the application is in the background, or the screen is off. The player state + * (including position) is rarely updated (between 10s and 1min). + * + *

        While offload scheduling is enabled, player events may be delivered severely delayed and + * apps should not interact with the player. When returning to the foreground, disable offload + * scheduling before interacting with the player + * + *

        This mode should save significant power when the phone is playing offload audio with the + * screen off. + * + *

        This mode only has an effect when playing an audio track in offload mode, which requires all + * the following: + * + *

          + *
        • audio offload rendering is enabled in {@link + * DefaultRenderersFactory#setEnableAudioOffload} or the equivalent option passed to {@link + * com.google.android.exoplayer2.audio.DefaultAudioSink#DefaultAudioSink(AudioCapabilities, + * DefaultAudioSink.AudioProcessorChain, boolean, boolean)}. + *
        • an audio track is playing in a format which the device supports offloading (for example + * MP3 or AAC). + *
        • The {@link com.google.android.exoplayer2.audio.AudioSink} is playing with an offload + * {@link android.media.AudioTrack}. + *
        + * + *

        This method is experimental, and will be renamed or removed in a future release. + * + * @param enableOffloadScheduling Whether to enable offload scheduling. + */ + void experimental_enableOffloadScheduling(boolean enableOffloadScheduling); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 26357a18dc..51c8a9ea60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -202,6 +202,11 @@ import java.util.concurrent.TimeoutException; internalPlayer.experimental_throwWhenStuckBuffering(); } + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + internalPlayer.experimental_enableOffloadScheduling(enableOffloadScheduling); + } + @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 53c8a5d080..c5e6b06c19 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -94,6 +94,15 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; + /** + * Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant + * power saving. + * + *

        This value is probably too high, power measurements are needed adjust it, but as renderer + * sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s, + * this does not matter for now. + */ + private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -127,6 +136,8 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; + private boolean requestForRendererSleep; + private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; @@ -199,6 +210,13 @@ import java.util.concurrent.atomic.AtomicBoolean; throwWhenStuckBuffering = true; } + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + offloadSchedulingEnabled = enableOffloadScheduling; + if (!enableOffloadScheduling) { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + } + public void prepare() { handler.obtainMessage(MSG_PREPARE).sendToTarget(); } @@ -885,12 +903,13 @@ import java.util.concurrent.atomic.AtomicBoolean; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } + requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -900,6 +919,14 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } + private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { + if (offloadSchedulingEnabled && requestForRendererSleep) { + return; + } + + scheduleNextWork(operationStartTimeMs, intervalMs); + } + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -2068,6 +2095,24 @@ import java.util.concurrent.atomic.AtomicBoolean; joining, mayRenderStartOfStream, periodHolder.getRendererOffset()); + + renderer.handleMessage( + Renderer.MSG_SET_WAKEUP_LISTENER, + new Renderer.WakeupListener() { + @Override + public void onSleep(long wakeupDeadlineMs) { + // Do not sleep if the expected sleep time is not long enough to save significant power. + if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) { + requestForRendererSleep = true; + } + } + + @Override + public void onWakeup() { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + }); + mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index fa73f9257d..8620c2d752 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -46,6 +46,30 @@ import java.lang.annotation.RetentionPolicy; */ public interface Renderer extends PlayerMessage.Target { + /** + * Some renderers can signal when {@link #render(long, long)} should be called. + * + *

        That allows the player to sleep until the next wakeup, instead of calling {@link + * #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save + * power. + */ + interface WakeupListener { + + /** + * The renderer no longer needs to render until the next wakeup. + * + * @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be + * called. + */ + void onSleep(long wakeupDeadlineMs); + + /** + * The renderer needs to render some frames. The client should call {@link #render(long, long)} + * at its earliest convenience. + */ + void onWakeup(); + } + /** * The type of a message that can be passed to a video renderer via {@link * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or @@ -137,6 +161,14 @@ public interface Renderer extends PlayerMessage.Target { * representing the audio session ID that will be attached to the underlying audio track. */ int MSG_SET_AUDIO_SESSION_ID = 102; + /** + * A type of a message that can be passed to a {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another + * component. + * + *

        The message payload must be a {@link WakeupListener} instance. + */ + int MSG_SET_WAKEUP_LISTENER = 103; /** * Applications or extensions may define custom {@code MSG_*} constants that can be passed to * renderers. These custom constants must be greater than or equal to this value. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index d1f0cfc798..4c36f9fc99 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -633,6 +633,11 @@ public class SimpleExoPlayer extends BasePlayer C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); } + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + player.experimental_enableOffloadScheduling(enableOffloadScheduling); + } + @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index c4fa25d6bf..8bebd97a67 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -90,6 +90,17 @@ public interface AudioSink { * @param skipSilenceEnabled Whether skipping silences is enabled. */ void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled); + + /** Called when the offload buffer has been partially emptied. */ + default void onOffloadBufferEmptying() {} + + /** + * Called when the offload buffer has been filled completely. + * + * @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link + * #onOffloadBufferEmptying()} will be called. + */ + default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index d15fe44fc0..ae2eb92044 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -335,6 +335,11 @@ import java.lang.reflect.Method; return bufferSize - bytesPending; } + /** Returns the duration of audio that is buffered but unplayed. */ + public long getPendingBufferDurationMs(long writtenFrames) { + return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition())); + } + /** Returns whether the track is in an invalid state and must be recreated. */ public boolean isStalled(long writtenFrames) { return forceResetWorkaroundTimeMs != C.TIME_UNSET diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index bc3c321cac..fdd684a269 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -20,6 +20,7 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; import android.os.ConditionVariable; +import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -274,6 +275,7 @@ public final class DefaultAudioSink implements AudioSink { private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; private final boolean enableOffload; + @MonotonicNonNull private StreamEventCallback offloadStreamEventCallback; @Nullable private Listener listener; /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ @@ -304,7 +306,7 @@ public final class DefaultAudioSink implements AudioSink { @Nullable private ByteBuffer inputBuffer; private int inputBufferAccessUnitCount; @Nullable private ByteBuffer outputBuffer; - private byte[] preV21OutputBuffer; + @MonotonicNonNull private byte[] preV21OutputBuffer; private int preV21OutputBufferOffset; private int drainingAudioProcessorIndex; private boolean handledEndOfStream; @@ -366,7 +368,10 @@ public final class DefaultAudioSink implements AudioSink { * be available when float output is in use. * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played * with offload and encoded audio passthrough, it will be played in offload. Audio offload is - * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). + * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android + * devices can only support one offload {@link android.media.AudioTrack} at a time and can + * invalidate it at any time. Thus an app can never be guaranteed that it will be able to play + * in offload. */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, @@ -404,6 +409,7 @@ public final class DefaultAudioSink implements AudioSink { activeAudioProcessors = new AudioProcessor[0]; outputBuffers = new ByteBuffer[0]; mediaPositionParametersCheckpoints = new ArrayDeque<>(); + offloadStreamEventCallback = Util.SDK_INT >= 29 ? new StreamEventCallback() : null; } // AudioSink implementation. @@ -563,6 +569,9 @@ public final class DefaultAudioSink implements AudioSink { audioTrack = Assertions.checkNotNull(configuration) .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + if (isOffloadedPlayback(audioTrack)) { + registerStreamEventCallback(audioTrack); + } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { @@ -744,6 +753,16 @@ public final class DefaultAudioSink implements AudioSink { return false; } + @RequiresApi(29) + private void registerStreamEventCallback(AudioTrack audioTrack) { + if (offloadStreamEventCallback == null) { + // Must be lazily initialized to receive stream event callbacks on the current (playback) + // thread as the constructor is not called in the playback thread. + offloadStreamEventCallback = new StreamEventCallback(); + } + offloadStreamEventCallback.register(audioTrack); + } + private void processBuffers(long avSyncPresentationTimeUs) throws WriteException { int count = activeAudioProcessors.length; int index = count; @@ -822,6 +841,15 @@ public final class DefaultAudioSink implements AudioSink { throw new WriteException(bytesWritten); } + if (playing + && listener != null + && bytesWritten < bytesRemaining + && isOffloadedPlayback(audioTrack)) { + long pendingDurationMs = + audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); + listener.onOffloadBufferFull(pendingDurationMs); + } + if (configuration.isInputPcm) { writtenPcmBytes += bytesWritten; } @@ -1040,6 +1068,9 @@ public final class DefaultAudioSink implements AudioSink { if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } + if (isOffloadedPlayback(audioTrack)) { + Assertions.checkNotNull(offloadStreamEventCallback).unregister(audioTrack); + } // AudioTrack.release can take some time, so we call it on a background thread. final AudioTrack toRelease = audioTrack; audioTrack = null; @@ -1229,6 +1260,36 @@ public final class DefaultAudioSink implements AudioSink { audioFormat, audioAttributes.getAudioAttributesV21()); } + private static boolean isOffloadedPlayback(AudioTrack audioTrack) { + return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); + } + + @RequiresApi(29) + private final class StreamEventCallback extends AudioTrack.StreamEventCallback { + private final Handler handler; + + public StreamEventCallback() { + handler = new Handler(); + } + + @Override + public void onDataRequest(AudioTrack track, int size) { + Assertions.checkState(track == DefaultAudioSink.this.audioTrack); + if (listener != null) { + listener.onOffloadBufferEmptying(); + } + } + + public void register(AudioTrack audioTrack) { + audioTrack.registerStreamEventCallback(handler::post, this); + } + + public void unregister(AudioTrack audioTrack) { + audioTrack.unregisterStreamEventCallback(this); + handler.removeCallbacksAndMessages(/* token= */ null); + } + } + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index a4816c5372..a2a48d6f09 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -92,6 +92,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean allowFirstBufferPositionDiscontinuity; private boolean allowPositionDiscontinuity; + @Nullable private WakeupListener wakeupListener; + /** * @param context A context. * @param mediaCodecSelector A decoder selector. @@ -696,6 +698,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case MSG_SET_AUDIO_SESSION_ID: audioSink.setAudioSessionId((Integer) message); break; + case MSG_SET_WAKEUP_LISTENER: + this.wakeupListener = (WakeupListener) message; + break; default: super.handleMessage(messageType, message); break; @@ -875,5 +880,19 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } + + @Override + public void onOffloadBufferEmptying() { + if (wakeupListener != null) { + wakeupListener.onWakeup(); + } + } + + @Override + public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { + if (wakeupListener != null) { + wakeupListener.onSleep(bufferEmptyingDeadlineMs); + } + } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index b4678cb7cf..c79a128f81 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -465,4 +465,9 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { public boolean getPauseAtEndOfMediaItems() { throw new UnsupportedOperationException(); } + + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + throw new UnsupportedOperationException(); + } } From 733e71b4dee22fac728952d864765cf256a41fbf Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 17 Jun 2020 17:09:51 +0100 Subject: [PATCH 0504/1052] Remove overloaded set method PiperOrigin-RevId: 316900193 --- .../exoplayer2/ext/cast/CastTimeline.java | 4 +- .../google/android/exoplayer2/Timeline.java | 38 ------------- .../source/SinglePeriodTimeline.java | 53 +------------------ .../android/exoplayer2/TimelineTest.java | 33 ------------ 4 files changed, 4 insertions(+), 124 deletions(-) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java index 38a7a692b2..edd2a060d2 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java @@ -15,10 +15,12 @@ */ package com.google.android.exoplayer2.ext.cast; +import android.net.Uri; import android.util.SparseArray; import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import java.util.Arrays; @@ -126,7 +128,7 @@ import java.util.Arrays; boolean isDynamic = durationUs == C.TIME_UNSET; return window.set( /* uid= */ ids[windowIndex], - /* tag= */ ids[windowIndex], + /* mediaItem= */ new MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build(), /* manifest= */ null, /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index 7e22671f00..fa062bbbff 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -225,44 +225,6 @@ public abstract class Timeline { mediaItem = DUMMY_MEDIA_ITEM; } - /** - * @deprecated Use {@link #set(Object, MediaItem, Object, long, long, long, boolean, boolean, - * boolean, long, long, int, int, long)} instead. - */ - @Deprecated - public Window set( - Object uid, - @Nullable Object tag, - @Nullable Object manifest, - long presentationStartTimeMs, - long windowStartTimeMs, - long elapsedRealtimeEpochOffsetMs, - boolean isSeekable, - boolean isDynamic, - boolean isLive, - long defaultPositionUs, - long durationUs, - int firstPeriodIndex, - int lastPeriodIndex, - long positionInFirstPeriodUs) { - set( - uid, - DUMMY_MEDIA_ITEM.buildUpon().setTag(tag).build(), - manifest, - presentationStartTimeMs, - windowStartTimeMs, - elapsedRealtimeEpochOffsetMs, - isSeekable, - isDynamic, - isLive, - defaultPositionUs, - durationUs, - firstPeriodIndex, - lastPeriodIndex, - positionInFirstPeriodUs); - return this; - } - /** Sets the data held by this window. */ @SuppressWarnings("deprecation") public Window set( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java index c841829c48..a99ceb6951 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -46,7 +46,6 @@ public final class SinglePeriodTimeline extends Timeline { private final boolean isSeekable; private final boolean isDynamic; private final boolean isLive; - @Nullable private final Object tag; @Nullable private final Object manifest; @Nullable private final MediaItem mediaItem; @@ -208,8 +207,7 @@ public final class SinglePeriodTimeline extends Timeline { isDynamic, isLive, manifest, - MEDIA_ITEM.buildUpon().setTag(tag).build(), - tag); + MEDIA_ITEM.buildUpon().setTag(tag).build()); } /** @@ -248,36 +246,6 @@ public final class SinglePeriodTimeline extends Timeline { boolean isLive, @Nullable Object manifest, MediaItem mediaItem) { - this( - presentationStartTimeMs, - windowStartTimeMs, - elapsedRealtimeEpochOffsetMs, - periodDurationUs, - windowDurationUs, - windowPositionInPeriodUs, - windowDefaultStartPositionUs, - isSeekable, - isDynamic, - isLive, - manifest, - mediaItem, - /* tag= */ null); - } - - private SinglePeriodTimeline( - long presentationStartTimeMs, - long windowStartTimeMs, - long elapsedRealtimeEpochOffsetMs, - long periodDurationUs, - long windowDurationUs, - long windowPositionInPeriodUs, - long windowDefaultStartPositionUs, - boolean isSeekable, - boolean isDynamic, - boolean isLive, - @Nullable Object manifest, - MediaItem mediaItem, - @Nullable Object tag) { this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs; @@ -290,7 +258,6 @@ public final class SinglePeriodTimeline extends Timeline { this.isLive = isLive; this.manifest = manifest; this.mediaItem = checkNotNull(mediaItem); - this.tag = tag; } @Override @@ -316,24 +283,6 @@ public final class SinglePeriodTimeline extends Timeline { } } } - if (tag != null) { - // Support deprecated constructors. - return window.set( - Window.SINGLE_WINDOW_UID, - tag, - manifest, - presentationStartTimeMs, - windowStartTimeMs, - elapsedRealtimeEpochOffsetMs, - isSeekable, - isDynamic, - isLive, - windowDefaultStartPositionUs, - windowDurationUs, - /* firstPeriodIndex= */ 0, - /* lastPeriodIndex= */ 0, - windowPositionInPeriodUs); - } return window.set( Window.SINGLE_WINDOW_UID, mediaItem, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java index 06fb452444..65b0119354 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2; import static com.google.common.truth.Truth.assertThat; -import android.net.Uri; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.FakeTimeline; @@ -141,38 +140,6 @@ public class TimelineTest { assertThat(window).isEqualTo(otherWindow); } - @SuppressWarnings("deprecation") - @Test - public void windowSet_withTag() { - Object tag = new Object(); - Timeline.Window window = - populateWindow( - new MediaItem.Builder() - .setMediaId("com.google.android.exoplayer2.Timeline") - .setUri(Uri.EMPTY) - .setTag(tag) - .build(), - tag); - Timeline.Window otherWindow = new Timeline.Window(); - otherWindow = - otherWindow.set( - window.uid, - window.tag, - window.manifest, - window.presentationStartTimeMs, - window.windowStartTimeMs, - window.elapsedRealtimeEpochOffsetMs, - window.isSeekable, - window.isDynamic, - window.isLive, - window.defaultPositionUs, - window.durationUs, - window.firstPeriodIndex, - window.lastPeriodIndex, - window.positionInFirstPeriodUs); - assertThat(window).isEqualTo(otherWindow); - } - @Test public void windowHashCode() { Timeline.Window window = new Timeline.Window(); From a5bc91f09b0f135ba32bf31eea5f0063a377fd81 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 17 Jun 2020 17:22:17 +0100 Subject: [PATCH 0505/1052] Remove unused previousExtractor from HlsExtractorFactory PiperOrigin-RevId: 316902430 --- .../exoplayer2/source/hls/DefaultHlsExtractorFactory.java | 1 - .../android/exoplayer2/source/hls/HlsExtractorFactory.java | 4 ---- .../google/android/exoplayer2/source/hls/HlsMediaChunk.java | 1 - .../source/hls/DefaultHlsExtractorFactoryTest.java | 5 ----- 4 files changed, 11 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 0fe89d4a4e..0a9ead7c48 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -89,7 +89,6 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { @Override public BundledHlsMediaChunkExtractor createExtractor( - @Nullable HlsMediaChunkExtractor previousExtractor, Uri uri, Format format, @Nullable List muxedCaptionFormats, diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java index de2ecd73b4..4fe78514cf 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java @@ -36,9 +36,6 @@ public interface HlsExtractorFactory { /** * Creates an {@link Extractor} for extracting HLS media chunks. * - * @param previousExtractor A previously used {@link Extractor} which can be reused if the current - * chunk is a continuation of the previously extracted chunk, or null otherwise. It is the - * responsibility of implementers to only reuse extractors that are suited for reusage. * @param uri The URI of the media chunk. * @param format A {@link Format} associated with the chunk to extract. * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption @@ -53,7 +50,6 @@ public interface HlsExtractorFactory { * @throws IOException If an I/O error is encountered while sniffing. */ HlsMediaChunkExtractor createExtractor( - @Nullable HlsMediaChunkExtractor previousExtractor, Uri uri, Format format, @Nullable List muxedCaptionFormats, diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 4c3b7655a7..3dec4fafd9 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -427,7 +427,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; extractor = extractorFactory.createExtractor( - previousExtractor, dataSpec.uri, trackFormat, muxedCaptionFormats, diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java index b3cd7caac4..b4b2e9edce 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java @@ -69,7 +69,6 @@ public class DefaultHlsExtractorFactoryTest { BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( - /* previousExtractor= */ null, tsUri, webVttFormat, /* muxedCaptionFormats= */ null, @@ -93,7 +92,6 @@ public class DefaultHlsExtractorFactoryTest { BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( - /* previousExtractor= */ null, tsUri, webVttFormat, /* muxedCaptionFormats= */ null, @@ -116,7 +114,6 @@ public class DefaultHlsExtractorFactoryTest { BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( - /* previousExtractor= */ null, tsUri, webVttFormat, /* muxedCaptionFormats= */ null, @@ -140,7 +137,6 @@ public class DefaultHlsExtractorFactoryTest { BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( - /* previousExtractor= */ null, tsUri, webVttFormat, /* muxedCaptionFormats= */ null, @@ -158,7 +154,6 @@ public class DefaultHlsExtractorFactoryTest { BundledHlsMediaChunkExtractor result = new DefaultHlsExtractorFactory() .createExtractor( - /* previousExtractor= */ null, tsUri, webVttFormat, /* muxedCaptionFormats= */ null, From ffa4ad0e77a24168430fb3ac4c2afd13b68a701a Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 17 Jun 2020 18:21:05 +0100 Subject: [PATCH 0506/1052] Rollback of https://github.com/google/ExoPlayer/commit/b6f5a263f725089c026bb8416ade555f4f16a2bc *** Original commit *** Rollforward of commit 5612ac50a332e425dc130c3c13a139b9e6fce9ec. *** Reason for rollforward *** Rollforward after making sure the handler is created from the playback thread and not from an app thread. *** Original change description *** Rollback of https://github.com/google/ExoPlayer/commit/e1beb1d1946bb8ca94f62578aee8cbadd97b6e2b *** Original commit *** Expose experimental offload scheduling Add a new scheduling mode that stops ExoPlayer main loop when the audio offload buffer is full and resume it... *** PiperOrigin-RevId: 316914147 --- RELEASENOTES.md | 1 - .../exoplayer2/DefaultRenderersFactory.java | 17 ++--- .../google/android/exoplayer2/ExoPlayer.java | 37 ----------- .../android/exoplayer2/ExoPlayerImpl.java | 5 -- .../exoplayer2/ExoPlayerImplInternal.java | 47 +------------- .../google/android/exoplayer2/Renderer.java | 32 --------- .../android/exoplayer2/SimpleExoPlayer.java | 5 -- .../android/exoplayer2/audio/AudioSink.java | 11 ---- .../audio/AudioTrackPositionTracker.java | 5 -- .../exoplayer2/audio/DefaultAudioSink.java | 65 +------------------ .../audio/MediaCodecAudioRenderer.java | 19 ------ .../exoplayer2/testutil/StubExoPlayer.java | 5 -- 12 files changed, 7 insertions(+), 242 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 196074485a..0073540f1c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -164,7 +164,6 @@ * No longer use a `MediaCodec` in audio passthrough mode. * Check `DefaultAudioSink` supports passthrough, in addition to checking the `AudioCapabilities` - * Add an experimental scheduling mode to save power in offload. ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Adjust input timestamps in `MediaCodecRenderer` to account for the Codec2 MP3 decoder having lower timestamps on the output side. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index 3913922c3c..bd56974b32 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -219,20 +219,12 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Sets whether audio should be played using the offload path. - * - *

        Audio offload disables ExoPlayer audio processing, but significantly reduces the energy - * consumption of the playback when {@link - * ExoPlayer#experimental_enableOffloadScheduling(boolean)} is enabled. - * - *

        Most Android devices can only support one offload {@link android.media.AudioTrack} at a time - * and can invalidate it at any time. Thus an app can never be guaranteed that it will be able to - * play in offload. + * Sets whether audio should be played using the offload path. Audio offload disables audio + * processors (for example speed adjustment). * *

        The default value is {@code false}. * - * @param enableOffload Whether to enable use of audio offload for supported formats, if - * available. + * @param enableOffload If audio offload should be used. * @return This factory, for convenience. */ public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { @@ -431,8 +423,7 @@ public class DefaultRenderersFactory implements RenderersFactory { * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param enableOffload Whether to enable use of audio offload for supported formats, if - * available. + * @param enableOffload If the renderer should use audio offload for all supported formats. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b3b369b68e..b4cd9a399d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -20,8 +20,6 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.analytics.AnalyticsCollector; -import com.google.android.exoplayer2.audio.AudioCapabilities; -import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; @@ -599,39 +597,4 @@ public interface ExoPlayer extends Player { * @see #setPauseAtEndOfMediaItems(boolean) */ boolean getPauseAtEndOfMediaItems(); - - /** - * Enables audio offload scheduling, which runs ExoPlayer's main loop as rarely as possible when - * playing an audio stream using audio offload. - * - *

        Only use this scheduling mode if the player is not displaying anything to the user. For - * example when the application is in the background, or the screen is off. The player state - * (including position) is rarely updated (between 10s and 1min). - * - *

        While offload scheduling is enabled, player events may be delivered severely delayed and - * apps should not interact with the player. When returning to the foreground, disable offload - * scheduling before interacting with the player - * - *

        This mode should save significant power when the phone is playing offload audio with the - * screen off. - * - *

        This mode only has an effect when playing an audio track in offload mode, which requires all - * the following: - * - *

          - *
        • audio offload rendering is enabled in {@link - * DefaultRenderersFactory#setEnableAudioOffload} or the equivalent option passed to {@link - * com.google.android.exoplayer2.audio.DefaultAudioSink#DefaultAudioSink(AudioCapabilities, - * DefaultAudioSink.AudioProcessorChain, boolean, boolean)}. - *
        • an audio track is playing in a format which the device supports offloading (for example - * MP3 or AAC). - *
        • The {@link com.google.android.exoplayer2.audio.AudioSink} is playing with an offload - * {@link android.media.AudioTrack}. - *
        - * - *

        This method is experimental, and will be renamed or removed in a future release. - * - * @param enableOffloadScheduling Whether to enable offload scheduling. - */ - void experimental_enableOffloadScheduling(boolean enableOffloadScheduling); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 51c8a9ea60..26357a18dc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -202,11 +202,6 @@ import java.util.concurrent.TimeoutException; internalPlayer.experimental_throwWhenStuckBuffering(); } - @Override - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - internalPlayer.experimental_enableOffloadScheduling(enableOffloadScheduling); - } - @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index c5e6b06c19..53c8a5d080 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -94,15 +94,6 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; - /** - * Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant - * power saving. - * - *

        This value is probably too high, power measurements are needed adjust it, but as renderer - * sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s, - * this does not matter for now. - */ - private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -136,8 +127,6 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; - private boolean requestForRendererSleep; - private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; @@ -210,13 +199,6 @@ import java.util.concurrent.atomic.AtomicBoolean; throwWhenStuckBuffering = true; } - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - offloadSchedulingEnabled = enableOffloadScheduling; - if (!enableOffloadScheduling) { - handler.sendEmptyMessage(MSG_DO_SOME_WORK); - } - } - public void prepare() { handler.obtainMessage(MSG_PREPARE).sendToTarget(); } @@ -903,13 +885,12 @@ import java.util.concurrent.atomic.AtomicBoolean; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); + scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } - requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -919,14 +900,6 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } - private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { - if (offloadSchedulingEnabled && requestForRendererSleep) { - return; - } - - scheduleNextWork(operationStartTimeMs, intervalMs); - } - private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -2095,24 +2068,6 @@ import java.util.concurrent.atomic.AtomicBoolean; joining, mayRenderStartOfStream, periodHolder.getRendererOffset()); - - renderer.handleMessage( - Renderer.MSG_SET_WAKEUP_LISTENER, - new Renderer.WakeupListener() { - @Override - public void onSleep(long wakeupDeadlineMs) { - // Do not sleep if the expected sleep time is not long enough to save significant power. - if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) { - requestForRendererSleep = true; - } - } - - @Override - public void onWakeup() { - handler.sendEmptyMessage(MSG_DO_SOME_WORK); - } - }); - mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index 8620c2d752..fa73f9257d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -46,30 +46,6 @@ import java.lang.annotation.RetentionPolicy; */ public interface Renderer extends PlayerMessage.Target { - /** - * Some renderers can signal when {@link #render(long, long)} should be called. - * - *

        That allows the player to sleep until the next wakeup, instead of calling {@link - * #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save - * power. - */ - interface WakeupListener { - - /** - * The renderer no longer needs to render until the next wakeup. - * - * @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be - * called. - */ - void onSleep(long wakeupDeadlineMs); - - /** - * The renderer needs to render some frames. The client should call {@link #render(long, long)} - * at its earliest convenience. - */ - void onWakeup(); - } - /** * The type of a message that can be passed to a video renderer via {@link * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or @@ -161,14 +137,6 @@ public interface Renderer extends PlayerMessage.Target { * representing the audio session ID that will be attached to the underlying audio track. */ int MSG_SET_AUDIO_SESSION_ID = 102; - /** - * A type of a message that can be passed to a {@link Renderer} via {@link - * ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another - * component. - * - *

        The message payload must be a {@link WakeupListener} instance. - */ - int MSG_SET_WAKEUP_LISTENER = 103; /** * Applications or extensions may define custom {@code MSG_*} constants that can be passed to * renderers. These custom constants must be greater than or equal to this value. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 4c36f9fc99..d1f0cfc798 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -633,11 +633,6 @@ public class SimpleExoPlayer extends BasePlayer C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); } - @Override - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - player.experimental_enableOffloadScheduling(enableOffloadScheduling); - } - @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 8bebd97a67..c4fa25d6bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -90,17 +90,6 @@ public interface AudioSink { * @param skipSilenceEnabled Whether skipping silences is enabled. */ void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled); - - /** Called when the offload buffer has been partially emptied. */ - default void onOffloadBufferEmptying() {} - - /** - * Called when the offload buffer has been filled completely. - * - * @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link - * #onOffloadBufferEmptying()} will be called. - */ - default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index ae2eb92044..d15fe44fc0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -335,11 +335,6 @@ import java.lang.reflect.Method; return bufferSize - bytesPending; } - /** Returns the duration of audio that is buffered but unplayed. */ - public long getPendingBufferDurationMs(long writtenFrames) { - return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition())); - } - /** Returns whether the track is in an invalid state and must be recreated. */ public boolean isStalled(long writtenFrames) { return forceResetWorkaroundTimeMs != C.TIME_UNSET diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index fdd684a269..bc3c321cac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -20,7 +20,6 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; import android.os.ConditionVariable; -import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -275,7 +274,6 @@ public final class DefaultAudioSink implements AudioSink { private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; private final boolean enableOffload; - @MonotonicNonNull private StreamEventCallback offloadStreamEventCallback; @Nullable private Listener listener; /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ @@ -306,7 +304,7 @@ public final class DefaultAudioSink implements AudioSink { @Nullable private ByteBuffer inputBuffer; private int inputBufferAccessUnitCount; @Nullable private ByteBuffer outputBuffer; - @MonotonicNonNull private byte[] preV21OutputBuffer; + private byte[] preV21OutputBuffer; private int preV21OutputBufferOffset; private int drainingAudioProcessorIndex; private boolean handledEndOfStream; @@ -368,10 +366,7 @@ public final class DefaultAudioSink implements AudioSink { * be available when float output is in use. * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played * with offload and encoded audio passthrough, it will be played in offload. Audio offload is - * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android - * devices can only support one offload {@link android.media.AudioTrack} at a time and can - * invalidate it at any time. Thus an app can never be guaranteed that it will be able to play - * in offload. + * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, @@ -409,7 +404,6 @@ public final class DefaultAudioSink implements AudioSink { activeAudioProcessors = new AudioProcessor[0]; outputBuffers = new ByteBuffer[0]; mediaPositionParametersCheckpoints = new ArrayDeque<>(); - offloadStreamEventCallback = Util.SDK_INT >= 29 ? new StreamEventCallback() : null; } // AudioSink implementation. @@ -569,9 +563,6 @@ public final class DefaultAudioSink implements AudioSink { audioTrack = Assertions.checkNotNull(configuration) .buildAudioTrack(tunneling, audioAttributes, audioSessionId); - if (isOffloadedPlayback(audioTrack)) { - registerStreamEventCallback(audioTrack); - } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { @@ -753,16 +744,6 @@ public final class DefaultAudioSink implements AudioSink { return false; } - @RequiresApi(29) - private void registerStreamEventCallback(AudioTrack audioTrack) { - if (offloadStreamEventCallback == null) { - // Must be lazily initialized to receive stream event callbacks on the current (playback) - // thread as the constructor is not called in the playback thread. - offloadStreamEventCallback = new StreamEventCallback(); - } - offloadStreamEventCallback.register(audioTrack); - } - private void processBuffers(long avSyncPresentationTimeUs) throws WriteException { int count = activeAudioProcessors.length; int index = count; @@ -841,15 +822,6 @@ public final class DefaultAudioSink implements AudioSink { throw new WriteException(bytesWritten); } - if (playing - && listener != null - && bytesWritten < bytesRemaining - && isOffloadedPlayback(audioTrack)) { - long pendingDurationMs = - audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); - listener.onOffloadBufferFull(pendingDurationMs); - } - if (configuration.isInputPcm) { writtenPcmBytes += bytesWritten; } @@ -1068,9 +1040,6 @@ public final class DefaultAudioSink implements AudioSink { if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } - if (isOffloadedPlayback(audioTrack)) { - Assertions.checkNotNull(offloadStreamEventCallback).unregister(audioTrack); - } // AudioTrack.release can take some time, so we call it on a background thread. final AudioTrack toRelease = audioTrack; audioTrack = null; @@ -1260,36 +1229,6 @@ public final class DefaultAudioSink implements AudioSink { audioFormat, audioAttributes.getAudioAttributesV21()); } - private static boolean isOffloadedPlayback(AudioTrack audioTrack) { - return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); - } - - @RequiresApi(29) - private final class StreamEventCallback extends AudioTrack.StreamEventCallback { - private final Handler handler; - - public StreamEventCallback() { - handler = new Handler(); - } - - @Override - public void onDataRequest(AudioTrack track, int size) { - Assertions.checkState(track == DefaultAudioSink.this.audioTrack); - if (listener != null) { - listener.onOffloadBufferEmptying(); - } - } - - public void register(AudioTrack audioTrack) { - audioTrack.registerStreamEventCallback(handler::post, this); - } - - public void unregister(AudioTrack audioTrack) { - audioTrack.unregisterStreamEventCallback(this); - handler.removeCallbacksAndMessages(/* token= */ null); - } - } - private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index a2a48d6f09..a4816c5372 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -92,8 +92,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean allowFirstBufferPositionDiscontinuity; private boolean allowPositionDiscontinuity; - @Nullable private WakeupListener wakeupListener; - /** * @param context A context. * @param mediaCodecSelector A decoder selector. @@ -698,9 +696,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case MSG_SET_AUDIO_SESSION_ID: audioSink.setAudioSessionId((Integer) message); break; - case MSG_SET_WAKEUP_LISTENER: - this.wakeupListener = (WakeupListener) message; - break; default: super.handleMessage(messageType, message); break; @@ -880,19 +875,5 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } - - @Override - public void onOffloadBufferEmptying() { - if (wakeupListener != null) { - wakeupListener.onWakeup(); - } - } - - @Override - public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { - if (wakeupListener != null) { - wakeupListener.onSleep(bufferEmptyingDeadlineMs); - } - } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index c79a128f81..b4678cb7cf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -465,9 +465,4 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { public boolean getPauseAtEndOfMediaItems() { throw new UnsupportedOperationException(); } - - @Override - public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { - throw new UnsupportedOperationException(); - } } From 92fd3bc2ff7f9c084d266a4904ef8ff83baa57da Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 17 Jun 2020 21:07:38 +0100 Subject: [PATCH 0507/1052] Bump version to 2.11.6 PiperOrigin-RevId: 316949571 --- RELEASENOTES.md | 15 +++++++++------ constants.gradle | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0073540f1c..96370e7e5b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -203,14 +203,19 @@ ([#6926](https://github.com/google/ExoPlayer/issues/6926)). * Update `TrackSelectionDialogBuilder` to use AndroidX Compat Dialog ([#7357](https://github.com/google/ExoPlayer/issues/7357)). - * Prevent the video surface going black when seeking to an unprepared - period within the current window. For example when seeking over an ad - group, or to the next period in a multi-period DASH stream - ([#5507](https://github.com/google/ExoPlayer/issues/5507)). * Metadata: Add minimal DVB Application Information Table (AIT) support ([#6922](https://github.com/google/ExoPlayer/pull/6922)). * Cast extension: Implement playlist API and deprecate the old queue manipulation API. +* Demo app: Retain previous position in list of samples. +* Add Guava dependency. + +### 2.11.6 (2020-06-19) ### + +* UI: Prevent `PlayerView` from temporarily hiding the video surface when + seeking to an unprepared period within the current window. For example when + seeking over an ad group, or to the next period in a multi-period DASH + stream ([#5507](https://github.com/google/ExoPlayer/issues/5507)). * IMA extension: * Add option to skip ads before the start position. * Catch unexpected errors in `stopAd` to avoid a crash @@ -223,8 +228,6 @@ * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded on seeking to another position ([#7492](https://github.com/google/ExoPlayer/issues/7492)). -* Demo app: Retain previous position in list of samples. -* Add Guava dependency. ### 2.11.5 (2020-06-05) ### diff --git a/constants.gradle b/constants.gradle index d08fd38ec1..9f753ec3cd 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.5' - releaseVersionCode = 2011005 + releaseVersion = '2.11.6' + releaseVersionCode = 2011006 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved. Also fix TODOs in UtilTest. From 88883ffd677f2936e23a9934cf52180bfe3066c2 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 17 Jun 2020 23:17:01 +0100 Subject: [PATCH 0508/1052] Generalize MimeTypes.isWebm to MimeTypes.isMatroska It seems more natural given we always end up instantiating a Matroska extractor, not one that's specific to the WebM subset of Matroska. There's also no reason not to support Matroska MIME types in DASH. PiperOrigin-RevId: 316975451 --- .../com/google/android/exoplayer2/util/MimeTypes.java | 10 +++++++--- .../exoplayer2/source/dash/DefaultDashChunkSource.java | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index cbc06d157d..2847dda685 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -101,6 +101,7 @@ public final class MimeTypes { public static final String APPLICATION_MP4 = BASE_TYPE_APPLICATION + "/mp4"; public static final String APPLICATION_WEBM = BASE_TYPE_APPLICATION + "/webm"; + public static final String APPLICATION_MATROSKA = BASE_TYPE_APPLICATION + "/x-matroska"; public static final String APPLICATION_MPD = BASE_TYPE_APPLICATION + "/dash+xml"; public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; public static final String APPLICATION_SS = BASE_TYPE_APPLICATION + "/vnd.ms-sstr+xml"; @@ -539,14 +540,17 @@ public final class MimeTypes { } } - /** Returns whether the given {@code mimeType} is a WebM MIME type. */ - public static boolean isWebm(@Nullable String mimeType) { + /** Returns whether the given {@code mimeType} is a Matroska MIME type, including WebM. */ + public static boolean isMatroska(@Nullable String mimeType) { if (mimeType == null) { return false; } return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM) - || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); + || mimeType.startsWith(MimeTypes.APPLICATION_WEBM) + || mimeType.startsWith(MimeTypes.VIDEO_MATROSKA) + || mimeType.startsWith(MimeTypes.AUDIO_MATROSKA) + || mimeType.startsWith(MimeTypes.APPLICATION_MATROSKA); } /** diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index 5a9cf67d4f..7328a2992a 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -789,7 +789,7 @@ public class DefaultDashChunkSource implements DashChunkSource { // All other text types are raw formats that do not need an extractor. return null; } - } else if (MimeTypes.isWebm(containerMimeType)) { + } else if (MimeTypes.isMatroska(containerMimeType)) { extractor = new MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES); } else { int flags = 0; From 685061431c4449527de5d0c42e93e66b8c4f8f77 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 18 Jun 2020 11:52:09 +0100 Subject: [PATCH 0509/1052] Call onKeysRemoved (not onKeysRestored) when releasing offline keys Looks like this change was introduced in onKeysRemoved is currently not triggered in DefaultDrmSessionManager as far as I can tell. It seems like it should be called from here. PiperOrigin-RevId: 317072794 --- .../com/google/android/exoplayer2/drm/DefaultDrmSession.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index 7a304da988..ea7994868b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -467,7 +467,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; byte[] responseData = (byte[]) response; if (mode == DefaultDrmSessionManager.MODE_RELEASE) { mediaDrm.provideKeyResponse(Util.castNonNull(offlineLicenseKeySetId), responseData); - dispatchEvent(DrmSessionEventListener::onDrmKeysRestored); + dispatchEvent(DrmSessionEventListener::onDrmKeysRemoved); } else { byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, responseData); if ((mode == DefaultDrmSessionManager.MODE_DOWNLOAD From 590aade74b36daf16c7569f5e61b25cc5275eb24 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 18 Jun 2020 14:43:42 +0100 Subject: [PATCH 0510/1052] De-duplicate Cue.Builder detailed javadoc The details are available on the public fields, which are referenced with @see PiperOrigin-RevId: 317092269 --- .../google/android/exoplayer2/text/Cue.java | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java index b816c6608b..cb3dcbfad9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java @@ -587,39 +587,6 @@ public final class Cue { * Sets the position of the {@code lineAnchor} of the cue box within the viewport in the * direction orthogonal to the writing direction. * - *

        The interpretation of the {@code line} depends on the value of {@code lineType}. - * - *

          - *
        • {@link #LINE_TYPE_FRACTION} indicates that {@code line} is a fractional position within - * the viewport. - *
        • {@link #LINE_TYPE_NUMBER} indicates that {@code line} is a line number, where the size - * of each line is taken to be the size of the first line of the cue. - *
            - *
          • When {@code line} is greater than or equal to 0 lines count from the start of the - * viewport, with 0 indicating zero offset from the start edge. - *
          • When {@code line} is negative lines count from the end of the viewport, with -1 - * indicating zero offset from the end edge. - *
          • For horizontal text the line spacing is the height of the first line of the cue, - * and the start and end of the viewport are the top and bottom respectively. - *
          - *
        - * - *

        Note that it's particularly important to consider the effect of {@link #setLineAnchor(int) - * lineAnchor} when using {@link #LINE_TYPE_NUMBER}. - * - *

          - *
        • {@code (line == 0 && lineAnchor == ANCHOR_TYPE_START)} positions a (potentially - * multi-line) cue at the very start of the viewport. - *
        • {@code (line == -1 && lineAnchor == ANCHOR_TYPE_END)} positions a (potentially - * multi-line) cue at the very end of the viewport. - *
        • {@code (line == 0 && lineAnchor == ANCHOR_TYPE_END)} and {@code (line == -1 && - * lineAnchor == ANCHOR_TYPE_START)} position cues entirely outside of the viewport. - *
        • {@code (line == 1 && lineAnchor == ANCHOR_TYPE_END)} positions a cue so that only the - * last line is visible at the start of the viewport. - *
        • {@code (line == -2 && lineAnchor == ANCHOR_TYPE_START)} position a cue so that only its - * first line is visible at the end of the viewport. - *
        - * * @see Cue#line * @see Cue#lineType */ @@ -652,10 +619,6 @@ public final class Cue { /** * Sets the cue box anchor positioned by {@link #setLine(float, int) line}. * - *

        For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link - * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the top, middle and bottom of - * the cue box respectively. - * * @see Cue#lineAnchor */ public Builder setLineAnchor(@AnchorType int lineAnchor) { @@ -677,10 +640,6 @@ public final class Cue { * Sets the fractional position of the {@link #setPositionAnchor(int) positionAnchor} of the cue * box within the viewport in the direction orthogonal to {@link #setLine(float, int) line}. * - *

        For horizontal text, this is the horizontal position relative to the left of the viewport. - * Note that positioning is relative to the left of the viewport even in the case of - * right-to-left text. - * * @see Cue#position */ public Builder setPosition(float position) { @@ -701,10 +660,6 @@ public final class Cue { /** * Sets the cue box anchor positioned by {@link #setPosition(float) position}. * - *

        For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link - * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the left, middle and right of - * the cue box respectively. - * * @see Cue#positionAnchor */ public Builder setPositionAnchor(@AnchorType int positionAnchor) { From 816a364a51b20d59e2b8b35f306a2358f8aaf9bd Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 18 Jun 2020 19:46:37 +0100 Subject: [PATCH 0511/1052] Clean up MimeTypes Javadoc PiperOrigin-RevId: 317148010 --- .../android/exoplayer2/util/MimeTypes.java | 191 +++++++++--------- 1 file changed, 97 insertions(+), 94 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index 2847dda685..cf86be8c46 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.util; import android.text.TextUtils; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.audio.AacUtil; import java.util.ArrayList; @@ -28,19 +29,6 @@ import java.util.regex.Pattern; */ public final class MimeTypes { - /** An mp4a Object Type Indication (OTI) and its optional audio OTI is defined by RFC 6381. */ - public static final class Mp4aObjectType { - /** The Object Type Indication of the mp4a codec. */ - public final int objectTypeIndication; - /** The Audio Object Type Indication of the mp4a codec, or 0 if it is absent. */ - @AacUtil.AacAudioObjectType public final int audioObjectTypeIndication; - - private Mp4aObjectType(int objectTypeIndication, int audioObjectTypeIndication) { - this.objectTypeIndication = objectTypeIndication; - this.audioObjectTypeIndication = audioObjectTypeIndication; - } - } - public static final String BASE_TYPE_VIDEO = "video"; public static final String BASE_TYPE_AUDIO = "audio"; public static final String BASE_TYPE_TEXT = "text"; @@ -135,7 +123,7 @@ public final class MimeTypes { * via this method. If this method is used, it must be called before creating any player(s). * * @param mimeType The custom MIME type to register. - * @param codecPrefix The RFC 6381-style codec string prefix associated with the MIME type. + * @param codecPrefix The RFC 6381 codec string prefix associated with the MIME type. * @param trackType The {@link C}{@code .TRACK_TYPE_*} constant associated with the MIME type. * This value is ignored if the top-level type of {@code mimeType} is audio, video or text. */ @@ -181,13 +169,13 @@ public final class MimeTypes { } /** - * Returns true if it is known that all samples in a stream of the given sample MIME type are - * guaranteed to be sync samples (i.e., {@link C#BUFFER_FLAG_KEY_FRAME} is guaranteed to be set on - * every sample). + * Returns true if it is known that all samples in a stream of the given MIME type are guaranteed + * to be sync samples (i.e., {@link C#BUFFER_FLAG_KEY_FRAME} is guaranteed to be set on every + * sample). * - * @param mimeType The sample MIME type. - * @return True if it is known that all samples in a stream of the given sample MIME type are - * guaranteed to be sync samples. False otherwise, including if {@code null} is passed. + * @param mimeType A MIME type. + * @return True if it is known that all samples in a stream of the given MIME type are guaranteed + * to be sync samples. False otherwise, including if {@code null} is passed. */ public static boolean allSamplesAreSyncSamples(@Nullable String mimeType) { if (mimeType == null) { @@ -216,10 +204,10 @@ public final class MimeTypes { } /** - * Derives a video sample mimeType from a codecs attribute. + * Returns the first video MIME type derived from an RFC 6381 codecs string. * - * @param codecs The codecs attribute. - * @return The derived video mimeType, or null if it could not be derived. + * @param codecs An RFC 6381 codecs string. + * @return The first derived video MIME type, or {@code null}. */ @Nullable public static String getVideoMediaMimeType(@Nullable String codecs) { @@ -237,10 +225,10 @@ public final class MimeTypes { } /** - * Derives a audio sample mimeType from a codecs attribute. + * Returns the first audio MIME type derived from an RFC 6381 codecs string. * - * @param codecs The codecs attribute. - * @return The derived audio mimeType, or null if it could not be derived. + * @param codecs An RFC 6381 codecs string. + * @return The first derived audio MIME type, or {@code null}. */ @Nullable public static String getAudioMediaMimeType(@Nullable String codecs) { @@ -258,10 +246,10 @@ public final class MimeTypes { } /** - * Derives a text sample mimeType from a codecs attribute. + * Returns the first text MIME type derived from an RFC 6381 codecs string. * - * @param codecs The codecs attribute. - * @return The derived text mimeType, or null if it could not be derived. + * @param codecs An RFC 6381 codecs string. + * @return The first derived text MIME type, or {@code null}. */ @Nullable public static String getTextMediaMimeType(@Nullable String codecs) { @@ -279,10 +267,11 @@ public final class MimeTypes { } /** - * Derives a mimeType from a codec identifier, as defined in RFC 6381. + * Returns the MIME type corresponding to an RFC 6381 codec string, or {@code null} if it could + * not be determined. * - * @param codec The codec identifier to derive. - * @return The mimeType, or null if it could not be derived. + * @param codec An RFC 6381 codec string. + * @return The corresponding MIME type, or {@code null} if it could not be determined. */ @Nullable public static String getMediaMimeType(@Nullable String codec) { @@ -346,11 +335,11 @@ public final class MimeTypes { } /** - * Derives a mimeType from MP4 object type identifier, as defined in RFC 6381 and - * https://mp4ra.org/#/object_types. + * Returns the MIME type corresponding to an MP4 object type identifier, as defined in RFC 6381 + * and https://mp4ra.org/#/object_types. * - * @param objectType The objectType identifier to derive. - * @return The mimeType, or null if it could not be derived. + * @param objectType An MP4 object type identifier. + * @return The corresponding MIME type, or {@code null} if it could not be determined. */ @Nullable public static String getMimeTypeFromMp4ObjectType(int objectType) { @@ -402,12 +391,12 @@ public final class MimeTypes { } /** - * Returns the {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified MIME type. - * {@link C#TRACK_TYPE_UNKNOWN} if the MIME type is not known or the mapping cannot be - * established. + * Returns the {@link C}{@code .TRACK_TYPE_*} constant corresponding to a specified MIME type, or + * {@link C#TRACK_TYPE_UNKNOWN} if it could not be determined. * - * @param mimeType The MIME type. - * @return The {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified MIME type. + * @param mimeType A MIME type. + * @return The corresponding {@link C}{@code .TRACK_TYPE_*}, or {@link C#TRACK_TYPE_UNKNOWN} if it + * could not be determined. */ public static int getTrackType(@Nullable String mimeType) { if (TextUtils.isEmpty(mimeType)) { @@ -430,25 +419,24 @@ public final class MimeTypes { } /** - * Returns the {@link C}{@code .ENCODING_*} constant that corresponds to specified MIME type, if - * it is an encoded (non-PCM) audio format, or {@link C#ENCODING_INVALID} otherwise. + * Returns the {@link C.Encoding} constant corresponding to the specified audio MIME type and RFC + * 6381 codec string, or {@link C#ENCODING_INVALID} if the corresponding {@link C.Encoding} cannot + * be determined. * - * @param mimeType The MIME type. - * @param codecs Codecs of the format as described in RFC 6381, or null if unknown or not - * applicable. - * @return One of {@link C.Encoding} constants that corresponds to a specified MIME type, or - * {@link C#ENCODING_INVALID}. + * @param mimeType A MIME type. + * @param codec An RFC 6381 codec string, or {@code null} if unknown or not applicable. + * @return The corresponding {@link C.Encoding}, or {@link C#ENCODING_INVALID}. */ @C.Encoding - public static int getEncoding(String mimeType, @Nullable String codecs) { + public static int getEncoding(String mimeType, @Nullable String codec) { switch (mimeType) { case MimeTypes.AUDIO_MPEG: return C.ENCODING_MP3; case MimeTypes.AUDIO_AAC: - if (codecs == null) { + if (codec == null) { return C.ENCODING_INVALID; } - @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codecs); + @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codec); if (objectType == null) { return C.ENCODING_INVALID; } @@ -475,57 +463,19 @@ public final class MimeTypes { /** * Equivalent to {@code getTrackType(getMediaMimeType(codec))}. * - * @param codec The codec. - * @return The {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified codec. + * @param codec An RFC 6381 codec string. + * @return The corresponding {@link C}{@code .TRACK_TYPE_*}, or {@link C#TRACK_TYPE_UNKNOWN} if it + * could not be determined. */ public static int getTrackTypeOfCodec(String codec) { return getTrackType(getMediaMimeType(codec)); } - /** - * Retrieves the object type of an mp4 audio codec from its string as defined in RFC 6381. - * - *

        Per https://mp4ra.org/#/object_types and https://tools.ietf.org/html/rfc6381#section-3.3, an - * mp4 codec string has the form: - * - *

        -   *         ~~~~~~~~~~~~~~ Object Type Indication (OTI) byte in hex
        -   *    mp4a.[a-zA-Z0-9]{2}(.[0-9]{1,2})?
        -   *                         ~~~~~~~~~~ audio OTI, decimal. Only for certain OTI.
        -   * 
        - * - * For example: mp4a.40.2, has an OTI of 0x40 and an audio OTI of 2. - * - * @param codec The string as defined in RFC 6381 describing an mp4 audio codec. - * @return The {@link Mp4aObjectType} or {@code null} if the input is invalid. - */ - @Nullable - public static Mp4aObjectType getObjectTypeFromMp4aRFC6381CodecString(String codec) { - Matcher matcher = MP4A_RFC_6381_CODEC_PATTERN.matcher(codec); - if (!matcher.matches()) { - return null; - } - String objectTypeIndicationHex = Assertions.checkNotNull(matcher.group(1)); - @Nullable String audioObjectTypeIndicationDec = matcher.group(2); - int objectTypeIndication; - int audioObjectTypeIndication = 0; - try { - objectTypeIndication = Integer.parseInt(objectTypeIndicationHex, 16); - if (audioObjectTypeIndicationDec != null) { - audioObjectTypeIndication = Integer.parseInt(audioObjectTypeIndicationDec); - } - } catch (NumberFormatException e) { - return null; - } - return new Mp4aObjectType(objectTypeIndication, audioObjectTypeIndication); - } - /** * Normalizes the MIME type provided so that equivalent MIME types are uniquely represented. * - * @param mimeType The MIME type to normalize. The MIME type provided is returned if its - * normalized form is unknown. - * @return The normalized MIME type. + * @param mimeType A MIME type to normalize. + * @return The normalized MIME type, or the argument MIME type if its normalized form is unknown. */ public static String normalizeMimeType(String mimeType) { switch (mimeType) { @@ -596,6 +546,59 @@ public final class MimeTypes { // Prevent instantiation. } + /** + * Returns the {@link Mp4aObjectType} of an RFC 6381 MP4 audio codec string. + * + *

        Per https://mp4ra.org/#/object_types and https://tools.ietf.org/html/rfc6381#section-3.3, an + * MP4 codec string has the form: + * + *

        +   *         ~~~~~~~~~~~~~~ Object Type Indication (OTI) byte in hex
        +   *    mp4a.[a-zA-Z0-9]{2}(.[0-9]{1,2})?
        +   *                         ~~~~~~~~~~ audio OTI, decimal. Only for certain OTI.
        +   * 
        + * + * For example, mp4a.40.2 has an OTI of 0x40 and an audio OTI of 2. + * + * @param codec An RFC 6381 MP4 audio codec string. + * @return The {@link Mp4aObjectType}, or {@code null} if the input was invalid. + */ + @VisibleForTesting + @Nullable + /* package */ static Mp4aObjectType getObjectTypeFromMp4aRFC6381CodecString(String codec) { + Matcher matcher = MP4A_RFC_6381_CODEC_PATTERN.matcher(codec); + if (!matcher.matches()) { + return null; + } + String objectTypeIndicationHex = Assertions.checkNotNull(matcher.group(1)); + @Nullable String audioObjectTypeIndicationDec = matcher.group(2); + int objectTypeIndication; + int audioObjectTypeIndication = 0; + try { + objectTypeIndication = Integer.parseInt(objectTypeIndicationHex, 16); + if (audioObjectTypeIndicationDec != null) { + audioObjectTypeIndication = Integer.parseInt(audioObjectTypeIndicationDec); + } + } catch (NumberFormatException e) { + return null; + } + return new Mp4aObjectType(objectTypeIndication, audioObjectTypeIndication); + } + + /** An MP4A Object Type Indication (OTI) and its optional audio OTI is defined by RFC 6381. */ + @VisibleForTesting + /* package */ static final class Mp4aObjectType { + /** The Object Type Indication of the MP4A codec. */ + public final int objectTypeIndication; + /** The Audio Object Type Indication of the MP4A codec, or 0 if it is absent. */ + @AacUtil.AacAudioObjectType public final int audioObjectTypeIndication; + + public Mp4aObjectType(int objectTypeIndication, int audioObjectTypeIndication) { + this.objectTypeIndication = objectTypeIndication; + this.audioObjectTypeIndication = audioObjectTypeIndication; + } + } + private static final class CustomMimeType { public final String mimeType; public final String codecPrefix; From f8843441a2fcd278c9d785fd80d4b2741f320d1a Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 18 Jun 2020 20:04:48 +0100 Subject: [PATCH 0512/1052] Align flags between the core and extension FLAC extractors - It seems conceptually simpler for DefaultExtractorsFactory - It seems unlikely we'll need to diverge the two. In the case of workaround flags we can just have them be no-ops in the version that doesn't need them. PiperOrigin-RevId: 317151955 --- .../exoplayer2/ext/flac/FlacExtractor.java | 9 ++++++++- library/extractor/proguard-rules.txt | 2 +- .../extractor/DefaultExtractorsFactory.java | 19 +++++++++++-------- .../extractor/flac/FlacExtractor.java | 6 ++++++ 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 364cf80ef8..615b60c3e7 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -53,6 +53,11 @@ public final class FlacExtractor implements Extractor { /** Factory that returns one extractor which is a {@link FlacExtractor}. */ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + // LINT.IfChange + /* + * Flags in the two FLAC extractors should be kept in sync. If we ever change this then + * DefaultExtractorsFactory will need modifying, because it currently assumes this is the case. + */ /** * Flags controlling the behavior of the extractor. Possible flag value is {@link * #FLAG_DISABLE_ID3_METADATA}. @@ -68,7 +73,9 @@ public final class FlacExtractor implements Extractor { * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not * required. */ - public static final int FLAG_DISABLE_ID3_METADATA = 1; + public static final int FLAG_DISABLE_ID3_METADATA = + com.google.android.exoplayer2.extractor.flac.FlacExtractor.FLAG_DISABLE_ID3_METADATA; + // LINT.ThenChange(../../../../../../../../../../../library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java) private final ParsableByteArray outputBuffer; private final boolean id3MetadataDisabled; diff --git a/library/extractor/proguard-rules.txt b/library/extractor/proguard-rules.txt index 5f97a491cb..d79f79a4a1 100644 --- a/library/extractor/proguard-rules.txt +++ b/library/extractor/proguard-rules.txt @@ -3,7 +3,7 @@ # Constructors accessed via reflection in DefaultExtractorsFactory -dontnote com.google.android.exoplayer2.ext.flac.FlacExtractor -keepclassmembers class com.google.android.exoplayer2.ext.flac.FlacExtractor { - (); + (int); } # Don't warn about checkerframework and Kotlin annotations diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 5a49c93408..2eba1b1cca 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -63,7 +63,8 @@ import java.util.Map; *
      • AMR ({@link AmrExtractor}) *
      • FLAC *
          - *
        • If available, the FLAC extension extractor is used. + *
        • If available, the FLAC extension's {@code + * com.google.android.exoplayer2.ext.flac.FlacExtractor} is used. *
        • Otherwise, the core {@link FlacExtractor} is used. Note that Android devices do not * generally include a FLAC decoder before API 27. This can be worked around by using * the FLAC extension or the FFmpeg extension. @@ -108,7 +109,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { flacExtensionExtractorConstructor = Class.forName("com.google.android.exoplayer2.ext.flac.FlacExtractor") .asSubclass(Extractor.class) - .getConstructor(); + .getConstructor(int.class); } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) } catch (ClassNotFoundException e) { @@ -123,7 +124,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { private boolean constantBitrateSeekingEnabled; @AdtsExtractor.Flags private int adtsFlags; @AmrExtractor.Flags private int amrFlags; - @FlacExtractor.Flags private int coreFlacFlags; + @FlacExtractor.Flags private int flacFlags; @MatroskaExtractor.Flags private int matroskaFlags; @Mp4Extractor.Flags private int mp4Flags; @FragmentedMp4Extractor.Flags private int fragmentedMp4Flags; @@ -178,15 +179,17 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { } /** - * Sets flags for {@link FlacExtractor} instances created by the factory. + * Sets flags for {@link FlacExtractor} instances created by the factory. The flags are also used + * by {@code com.google.android.exoplayer2.ext.flac.FlacExtractor} instances if the FLAC extension + * is being used. * * @see FlacExtractor#FlacExtractor(int) * @param flags The flags to use. * @return The factory, for convenience. */ - public synchronized DefaultExtractorsFactory setCoreFlacExtractorFlags( + public synchronized DefaultExtractorsFactory setFlacExtractorFlags( @FlacExtractor.Flags int flags) { - this.coreFlacFlags = flags; + this.flacFlags = flags; return this; } @@ -324,13 +327,13 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { case FileTypes.FLAC: if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { try { - extractors.add(FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance()); + extractors.add(FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(flacFlags)); } catch (Exception e) { // Should never happen. throw new IllegalStateException("Unexpected error creating FLAC extractor", e); } } else { - extractors.add(new FlacExtractor(coreFlacFlags)); + extractors.add(new FlacExtractor(flacFlags)); } break; case FileTypes.FLV: diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java index f0da2656a1..6ca5e3fe5a 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -52,6 +52,11 @@ public final class FlacExtractor implements Extractor { /** Factory for {@link FlacExtractor} instances. */ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + // LINT.IfChange + /* + * Flags in the two FLAC extractors should be kept in sync. If we ever change this then + * DefaultExtractorsFactory will need modifying, because it currently assumes this is the case. + */ /** * Flags controlling the behavior of the extractor. Possible flag value is {@link * #FLAG_DISABLE_ID3_METADATA}. @@ -68,6 +73,7 @@ public final class FlacExtractor implements Extractor { * required. */ public static final int FLAG_DISABLE_ID3_METADATA = 1; + // LINT.ThenChange(../../../../../../../../../../../extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java) /** Parser state. */ @Documented From 6ae472243f16d1f075328a779f3d4b46e180b76d Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 19 Jun 2020 11:01:15 +0100 Subject: [PATCH 0513/1052] Rename Util methods to clarify which Looper is used. The method name didn't clarify that either the main or current Looper is used. PiperOrigin-RevId: 317276561 --- .../ext/leanback/LeanbackPlayerAdapter.java | 2 +- .../mediasession/MediaSessionConnector.java | 2 +- .../google/android/exoplayer2/util/Util.java | 13 ++--- .../google/android/exoplayer2/ExoPlayer.java | 2 +- .../android/exoplayer2/ExoPlayerFactory.java | 13 ++--- .../android/exoplayer2/MediaSourceList.java | 4 +- .../android/exoplayer2/SimpleExoPlayer.java | 2 +- .../audio/AudioCapabilitiesReceiver.java | 2 +- .../exoplayer2/offline/DownloadHelper.java | 7 +-- .../exoplayer2/offline/DownloadManager.java | 2 +- .../exoplayer2/offline/DownloadService.java | 2 +- .../scheduler/RequirementsWatcher.java | 2 +- .../source/CompositeMediaSource.java | 2 +- .../source/ProgressiveMediaPeriod.java | 2 +- .../exoplayer2/source/ads/AdsMediaSource.java | 2 +- .../video/MediaCodecVideoRenderer.java | 2 +- .../source/ClippingMediaSourceTest.java | 2 +- .../source/ConcatenatingMediaSourceTest.java | 49 +++++++++++++------ .../util/MediaSourceEventDispatcherTest.java | 32 +++++++++--- .../source/dash/DashMediaSource.java | 2 +- .../source/dash/PlayerEmsgHandler.java | 2 +- .../source/hls/HlsSampleStreamWrapper.java | 2 +- .../playlist/DefaultHlsPlaylistTracker.java | 2 +- .../source/smoothstreaming/SsMediaSource.java | 2 +- .../android/exoplayer2/testutil/Action.java | 6 +-- .../exoplayer2/testutil/ExoHostedTest.java | 3 +- .../exoplayer2/testutil/FakeMediaPeriod.java | 2 +- .../exoplayer2/testutil/FakeMediaSource.java | 2 +- .../exoplayer2/testutil/TestExoPlayer.java | 2 +- 29 files changed, 101 insertions(+), 68 deletions(-) diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java index e385cd52e9..6538160b8b 100644 --- a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java +++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java @@ -72,7 +72,7 @@ public final class LeanbackPlayerAdapter extends PlayerAdapter implements Runnab this.context = context; this.player = player; this.updatePeriodMs = updatePeriodMs; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); componentListener = new ComponentListener(); controlDispatcher = new DefaultControlDispatcher(); } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index b74ad9701f..f3edfa3545 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -437,7 +437,7 @@ public final class MediaSessionConnector { */ public MediaSessionConnector(MediaSessionCompat mediaSession) { this.mediaSession = mediaSession; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); componentListener = new ComponentListener(); commandReceivers = new ArrayList<>(); customCommandReceivers = new ArrayList<>(); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 09303c4a9c..96c2d3622a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -399,8 +399,8 @@ public final class Util { *

          If the current thread doesn't have a {@link Looper}, the application's main thread {@link * Looper} is used. */ - public static Handler createHandler() { - return createHandler(/* callback= */ null); + public static Handler createHandlerForCurrentOrMainLooper() { + return createHandlerForCurrentOrMainLooper(/* callback= */ null); } /** @@ -416,8 +416,9 @@ public final class Util { * callback is required. * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. */ - public static Handler createHandler(@Nullable Handler.@UnknownInitialization Callback callback) { - return createHandler(getLooper(), callback); + public static Handler createHandlerForCurrentOrMainLooper( + @Nullable Handler.@UnknownInitialization Callback callback) { + return createHandler(getCurrentOrMainLooper(), callback); } /** @@ -441,8 +442,8 @@ public final class Util { * Returns the {@link Looper} associated with the current thread, or the {@link Looper} of the * application's main thread if the current thread doesn't have a {@link Looper}. */ - public static Looper getLooper() { - Looper myLooper = Looper.myLooper(); + public static Looper getCurrentOrMainLooper() { + @Nullable Looper myLooper = Looper.myLooper(); return myLooper != null ? myLooper : Looper.getMainLooper(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b4cd9a399d..9990e77f3a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -209,7 +209,7 @@ public interface ExoPlayer extends Player { this.mediaSourceFactory = mediaSourceFactory; this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); useLazyPreparation = true; seekParameters = SeekParameters.DEFAULT; clock = Clock.DEFAULT; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index 2c07593aaa..dcdce89489 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -101,11 +101,7 @@ public final class ExoPlayerFactory { TrackSelector trackSelector, LoadControl loadControl) { return newSimpleInstance( - context, - renderersFactory, - trackSelector, - loadControl, - Util.getLooper()); + context, renderersFactory, trackSelector, loadControl, Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -124,7 +120,7 @@ public final class ExoPlayerFactory { loadControl, bandwidthMeter, new AnalyticsCollector(Clock.DEFAULT), - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -142,7 +138,7 @@ public final class ExoPlayerFactory { trackSelector, loadControl, analyticsCollector, - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -220,7 +216,8 @@ public final class ExoPlayerFactory { @SuppressWarnings("deprecation") public static ExoPlayer newInstance( Context context, Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { - return newInstance(context, renderers, trackSelector, loadControl, Util.getLooper()); + return newInstance( + context, renderers, trackSelector, loadControl, Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link ExoPlayer.Builder} instead. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java index e690ea3626..cffad118ad 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java @@ -437,8 +437,8 @@ import java.util.Set; (source, timeline) -> mediaSourceListInfoListener.onPlaylistUpdateRequested(); ForwardingEventListener eventListener = new ForwardingEventListener(holder); childSources.put(holder, new MediaSourceAndListener(mediaSource, caller, eventListener)); - mediaSource.addEventListener(Util.createHandler(), eventListener); - mediaSource.addDrmEventListener(Util.createHandler(), eventListener); + mediaSource.addEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); + mediaSource.addDrmEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); mediaSource.prepareSource(caller, mediaTransferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index d1f0cfc798..db2602ea85 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -199,7 +199,7 @@ public class SimpleExoPlayer extends BasePlayer this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; this.analyticsCollector = analyticsCollector; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); audioAttributes = AudioAttributes.DEFAULT; wakeMode = C.WAKE_MODE_NONE; videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java index 991ed9ee97..c9c78a7422 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java @@ -65,7 +65,7 @@ public final class AudioCapabilitiesReceiver { context = context.getApplicationContext(); this.context = context; this.listener = Assertions.checkNotNull(listener); - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); receiver = Util.SDK_INT >= 21 ? new HdmiAudioPlugBroadcastReceiver() : null; Uri externalSurroundSoundUri = AudioCapabilities.getExternalSurroundSoundGlobalSettingUri(); externalSurroundSoundSettingObserver = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 11933e7834..51b939f6ce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -156,7 +156,7 @@ public final class DownloadHelper { public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { Renderer[] renderers = renderersFactory.createRenderers( - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), new VideoRendererEventListener() {}, new AudioRendererEventListener() {}, (cues) -> {}, @@ -501,7 +501,7 @@ public final class DownloadHelper { this.rendererCapabilities = rendererCapabilities; this.scratchSet = new SparseIntArray(); trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); - callbackHandler = new Handler(Util.getLooper()); + callbackHandler = Util.createHandlerForCurrentOrMainLooper(); window = new Timeline.Window(); } @@ -970,7 +970,8 @@ public final class DownloadHelper { allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); pendingMediaPeriods = new ArrayList<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler downloadThreadHandler = Util.createHandler(this::handleDownloadHelperCallbackMessage); + Handler downloadThreadHandler = + Util.createHandlerForCurrentOrMainLooper(this::handleDownloadHelperCallbackMessage); this.downloadHelperHandler = downloadThreadHandler; mediaSourceThread = new HandlerThread("ExoPlayer:DownloadHelper"); mediaSourceThread.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 12f8182980..50df4a0e8a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -228,7 +228,7 @@ public final class DownloadManager { listeners = new CopyOnWriteArraySet<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler mainHandler = Util.createHandler(this::handleMainMessage); + Handler mainHandler = Util.createHandlerForCurrentOrMainLooper(this::handleMainMessage); this.applicationHandler = mainHandler; HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager"); internalThread.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index a0c08071db..527c51ea83 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -950,7 +950,7 @@ public abstract class DownloadService extends Service { // DownloadService.getForegroundNotification, and concrete subclass implementations may // not anticipate the possibility of this method being called before their onCreate // implementation has finished executing. - Util.createHandler() + Util.createHandlerForCurrentOrMainLooper() .postAtFrontOfQueue( () -> downloadService.notifyDownloads(downloadManager.getCurrentDownloads())); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index 849511ef3f..f0a9ae3efc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -71,7 +71,7 @@ public final class RequirementsWatcher { this.context = context.getApplicationContext(); this.listener = listener; this.requirements = requirements; - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java index b742d3b431..6693e53abe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -48,7 +48,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @CallSuper protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; - eventHandler = Util.createHandler(); + eventHandler = Util.createHandlerForCurrentOrMainLooper(); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index d879671c83..25283a0ecf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -192,7 +192,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); sampleQueueTrackIds = new TrackId[0]; sampleQueues = new SampleQueue[0]; pendingResetPositionUs = C.TIME_UNSET; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 27df9a66f3..3688c63ec1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -336,7 +336,7 @@ public final class AdsMediaSource extends CompositeMediaSource { * events on the external event listener thread. */ public ComponentListener() { - playerHandler = Util.createHandler(); + playerHandler = Util.createHandlerForCurrentOrMainLooper(); } /** Releases the component listener. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index aefd52ab11..814937717e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1763,7 +1763,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final Handler handler; public OnFrameRenderedListenerV23(MediaCodec codec) { - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index d3e85233e9..4f9331be62 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -599,7 +599,7 @@ public final class ClippingMediaSourceTest { testRunner.runOnPlaybackThread( () -> clippingMediaSource.addEventListener( - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), new MediaSourceEventListener() { @Override public void onDownstreamFormatChanged( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index cf2e3e879d..90e1eed47f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -412,7 +412,9 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), Util.createHandler(), runnableInvoked::countDown)); + createFakeMediaSource(), + Util.createHandlerForCurrentOrMainLooper(), + runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -428,7 +430,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -446,7 +448,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSource( /* index */ 0, createFakeMediaSource(), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -464,7 +466,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( /* index */ 0, Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -481,7 +483,9 @@ public final class ConcatenatingMediaSourceTest { () -> { mediaSource.addMediaSource(createFakeMediaSource()); mediaSource.removeMediaSource( - /* index */ 0, Util.createHandler(), runnableInvoked::countDown); + /* index */ 0, + Util.createHandlerForCurrentOrMainLooper(), + runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -499,7 +503,10 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()})); mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), runnableInvoked::countDown); + /* fromIndex */ 1, /* toIndex */ + 0, + Util.createHandlerForCurrentOrMainLooper(), + runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -516,7 +523,9 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), Util.createHandler(), timelineGrabber)); + createFakeMediaSource(), + Util.createHandlerForCurrentOrMainLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -535,7 +544,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -553,7 +562,10 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - /* index */ 0, createFakeMediaSource(), Util.createHandler(), timelineGrabber)); + /* index */ 0, + createFakeMediaSource(), + Util.createHandlerForCurrentOrMainLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -573,7 +585,7 @@ public final class ConcatenatingMediaSourceTest { /* index */ 0, Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -593,7 +605,8 @@ public final class ConcatenatingMediaSourceTest { final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); dummyMainThread.runOnMainThread( () -> - mediaSource.removeMediaSource(/* index */ 0, Util.createHandler(), timelineGrabber)); + mediaSource.removeMediaSource( + /* index */ 0, Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(0); } finally { @@ -617,7 +630,10 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), timelineGrabber)); + /* fromIndex */ 1, /* toIndex */ + 0, + Util.createHandlerForCurrentOrMainLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); } finally { @@ -638,7 +654,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* currentIndex= */ 0, /* newIndex= */ 1, - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), callbackCalledCondition::countDown); mediaSource.releaseSource(caller); }); @@ -890,7 +906,8 @@ public final class ConcatenatingMediaSourceTest { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread(() -> mediaSource.clear(Util.createHandler(), timelineGrabber)); + dummyMainThread.runOnMainThread( + () -> mediaSource.clear(Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.isEmpty()).isTrue(); @@ -1042,7 +1059,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 0), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -1062,7 +1079,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 3), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(0); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java index 8d110a8776..debf839a43 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java @@ -65,7 +65,9 @@ public class MediaSourceEventDispatcherTest { @Test public void listenerReceivesEventPopulatedWithMediaPeriodInfo() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.dispatch( MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); @@ -76,9 +78,13 @@ public class MediaSourceEventDispatcherTest { @Test public void sameListenerObjectRegisteredTwiceOnlyReceivesEventsOnce() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.dispatch( MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); @@ -154,7 +160,9 @@ public class MediaSourceEventDispatcherTest { @Test public void listenersAreCopiedToNewDispatcher() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); MediaSource.MediaPeriodId newPeriodId = new MediaSource.MediaPeriodId("different uid"); MediaSourceEventDispatcher newEventDispatcher = @@ -170,7 +178,9 @@ public class MediaSourceEventDispatcherTest { @Test public void removingListenerStopsEventDispatch() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( @@ -182,7 +192,9 @@ public class MediaSourceEventDispatcherTest { @Test public void removingListenerWithDifferentTypeToRegistrationDoesntRemove() { eventDispatcher.addEventListener( - Util.createHandler(), mediaAndDrmEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaAndDrmEventListener, + MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaAndDrmEventListener, DrmSessionEventListener.class); eventDispatcher.dispatch( @@ -195,9 +207,13 @@ public class MediaSourceEventDispatcherTest { public void listenersAreCountedBasedOnListenerAndType() { // Add the listener twice and remove it once. eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 747a24ca63..103b689dc2 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -680,7 +680,7 @@ public final class DashMediaSource extends BaseMediaSource { } else { dataSource = manifestDataSourceFactory.createDataSource(); loader = new Loader("Loader:DashMediaSource"); - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); startLoadingManifest(); } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index 7888841e23..fed5ab74f5 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -105,7 +105,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { this.allocator = allocator; manifestPublishTimeToExpiryTimeUs = new TreeMap<>(); - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); decoder = new EventMessageDecoder(); lastLoadedChunkEndTimeUs = C.TIME_UNSET; lastLoadedChunkEndTimeBeforeRefreshUs = C.TIME_UNSET; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 979b24f939..579af21bc4 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -227,7 +227,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @SuppressWarnings("nullness:methodref.receiver.bound.invalid") Runnable onTracksEndedRunnable = this::onTracksEnded; this.onTracksEndedRunnable = onTracksEndedRunnable; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); lastSeekPositionUs = positionUs; pendingResetPositionUs = positionUs; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index d43284a211..2806a0bdd4 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -121,7 +121,7 @@ public final class DefaultHlsPlaylistTracker Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener primaryPlaylistListener) { - this.playlistRefreshHandler = Util.createHandler(); + this.playlistRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); this.eventDispatcher = eventDispatcher; this.primaryPlaylistListener = primaryPlaylistListener; ParsingLoadable masterPlaylistLoadable = diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 6b9a00b486..9f63a54650 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -620,7 +620,7 @@ public final class SsMediaSource extends BaseMediaSource manifestDataSource = manifestDataSourceFactory.createDataSource(); manifestLoader = new Loader("Loader:Manifest"); manifestLoaderErrorThrower = manifestLoader; - manifestRefreshHandler = Util.createHandler(); + manifestRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); startLoadingManifest(); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index 8fe58aa45b..5b8d501d00 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -602,7 +602,7 @@ public abstract class Action { } else { message.setPosition(positionMs); } - message.setHandler(Util.createHandler()); + message.setHandler(Util.createHandlerForCurrentOrMainLooper()); message.setDeleteAfterDelivery(deleteAfterDelivery); message.send(); } @@ -684,7 +684,7 @@ public abstract class Action { @Nullable Surface surface, HandlerWrapper handler, @Nullable ActionNode nextAction) { - Handler testThreadHandler = Util.createHandler(); + Handler testThreadHandler = Util.createHandlerForCurrentOrMainLooper(); // Schedule a message on the playback thread to ensure the player is paused immediately. player .createMessage( @@ -1048,7 +1048,7 @@ public abstract class Action { player .createMessage( (type, data) -> nextAction.schedule(player, trackSelector, surface, handler)) - .setHandler(Util.createHandler()) + .setHandler(Util.createHandlerForCurrentOrMainLooper()) .send(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index ea3c74f26f..d8dabf05b0 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -137,7 +137,8 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { player.addAnalyticsListener(this); player.addAnalyticsListener(new EventLogger(trackSelector, tag)); // Schedule any pending actions. - actionHandler = Clock.DEFAULT.createHandler(Util.getLooper(), /* callback= */ null); + actionHandler = + Clock.DEFAULT.createHandler(Util.getCurrentOrMainLooper(), /* callback= */ null); if (pendingSchedule != null) { pendingSchedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null); pendingSchedule = null; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index 35fd2d7f0e..4e3d15b43f 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -162,7 +162,7 @@ public class FakeMediaPeriod implements MediaPeriod { /* mediaEndTimeUs = */ C.TIME_UNSET); prepareCallback = callback; if (deferOnPrepared) { - playerHandler = Util.createHandler(); + playerHandler = Util.createHandlerForCurrentOrMainLooper(); } else { finishPreparation(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index 2c5a471c58..ded1da49b9 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -176,7 +176,7 @@ public class FakeMediaSource extends BaseMediaSource { drmSessionManager.prepare(); preparedSource = true; releasedSource = false; - sourceInfoRefreshHandler = Util.createHandler(); + sourceInfoRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); if (timeline != null) { finishSourcePreparation(/* sendManifestLoadEvents= */ true); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java index 139088aeb6..548c0a0ccf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java @@ -490,7 +490,7 @@ public class TestExoPlayer { AtomicBoolean receivedMessageCallback = new AtomicBoolean(false); player .createMessage((type, data) -> receivedMessageCallback.set(true)) - .setHandler(Util.createHandler()) + .setHandler(Util.createHandlerForCurrentOrMainLooper()) .send(); runMainLooperUntil(receivedMessageCallback::get); } From 63ae4cc54bc58303faf15a0dc97017792b0de6f2 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 19 Jun 2020 12:16:05 +0100 Subject: [PATCH 0514/1052] Rollback of https://github.com/google/ExoPlayer/commit/6ae472243f16d1f075328a779f3d4b46e180b76d *** Original commit *** Rename Util methods to clarify which Looper is used. The method name didn't clarify that either the main or current Looper is used. *** PiperOrigin-RevId: 317283606 --- .../ext/leanback/LeanbackPlayerAdapter.java | 2 +- .../mediasession/MediaSessionConnector.java | 2 +- .../google/android/exoplayer2/util/Util.java | 13 +++-- .../google/android/exoplayer2/ExoPlayer.java | 2 +- .../android/exoplayer2/ExoPlayerFactory.java | 13 +++-- .../android/exoplayer2/MediaSourceList.java | 4 +- .../android/exoplayer2/SimpleExoPlayer.java | 2 +- .../audio/AudioCapabilitiesReceiver.java | 2 +- .../exoplayer2/offline/DownloadHelper.java | 7 ++- .../exoplayer2/offline/DownloadManager.java | 2 +- .../exoplayer2/offline/DownloadService.java | 2 +- .../scheduler/RequirementsWatcher.java | 2 +- .../source/CompositeMediaSource.java | 2 +- .../source/ProgressiveMediaPeriod.java | 2 +- .../exoplayer2/source/ads/AdsMediaSource.java | 2 +- .../video/MediaCodecVideoRenderer.java | 2 +- .../source/ClippingMediaSourceTest.java | 2 +- .../source/ConcatenatingMediaSourceTest.java | 49 ++++++------------- .../util/MediaSourceEventDispatcherTest.java | 32 +++--------- .../source/dash/DashMediaSource.java | 2 +- .../source/dash/PlayerEmsgHandler.java | 2 +- .../source/hls/HlsSampleStreamWrapper.java | 2 +- .../playlist/DefaultHlsPlaylistTracker.java | 2 +- .../source/smoothstreaming/SsMediaSource.java | 2 +- .../android/exoplayer2/testutil/Action.java | 6 +-- .../exoplayer2/testutil/ExoHostedTest.java | 3 +- .../exoplayer2/testutil/FakeMediaPeriod.java | 2 +- .../exoplayer2/testutil/FakeMediaSource.java | 2 +- .../exoplayer2/testutil/TestExoPlayer.java | 2 +- 29 files changed, 68 insertions(+), 101 deletions(-) diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java index 6538160b8b..e385cd52e9 100644 --- a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java +++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java @@ -72,7 +72,7 @@ public final class LeanbackPlayerAdapter extends PlayerAdapter implements Runnab this.context = context; this.player = player; this.updatePeriodMs = updatePeriodMs; - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandler(); componentListener = new ComponentListener(); controlDispatcher = new DefaultControlDispatcher(); } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index f3edfa3545..b74ad9701f 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -437,7 +437,7 @@ public final class MediaSessionConnector { */ public MediaSessionConnector(MediaSessionCompat mediaSession) { this.mediaSession = mediaSession; - looper = Util.getCurrentOrMainLooper(); + looper = Util.getLooper(); componentListener = new ComponentListener(); commandReceivers = new ArrayList<>(); customCommandReceivers = new ArrayList<>(); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 96c2d3622a..09303c4a9c 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -399,8 +399,8 @@ public final class Util { *

          If the current thread doesn't have a {@link Looper}, the application's main thread {@link * Looper} is used. */ - public static Handler createHandlerForCurrentOrMainLooper() { - return createHandlerForCurrentOrMainLooper(/* callback= */ null); + public static Handler createHandler() { + return createHandler(/* callback= */ null); } /** @@ -416,9 +416,8 @@ public final class Util { * callback is required. * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. */ - public static Handler createHandlerForCurrentOrMainLooper( - @Nullable Handler.@UnknownInitialization Callback callback) { - return createHandler(getCurrentOrMainLooper(), callback); + public static Handler createHandler(@Nullable Handler.@UnknownInitialization Callback callback) { + return createHandler(getLooper(), callback); } /** @@ -442,8 +441,8 @@ public final class Util { * Returns the {@link Looper} associated with the current thread, or the {@link Looper} of the * application's main thread if the current thread doesn't have a {@link Looper}. */ - public static Looper getCurrentOrMainLooper() { - @Nullable Looper myLooper = Looper.myLooper(); + public static Looper getLooper() { + Looper myLooper = Looper.myLooper(); return myLooper != null ? myLooper : Looper.getMainLooper(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 9990e77f3a..b4cd9a399d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -209,7 +209,7 @@ public interface ExoPlayer extends Player { this.mediaSourceFactory = mediaSourceFactory; this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; - looper = Util.getCurrentOrMainLooper(); + looper = Util.getLooper(); useLazyPreparation = true; seekParameters = SeekParameters.DEFAULT; clock = Clock.DEFAULT; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index dcdce89489..2c07593aaa 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -101,7 +101,11 @@ public final class ExoPlayerFactory { TrackSelector trackSelector, LoadControl loadControl) { return newSimpleInstance( - context, renderersFactory, trackSelector, loadControl, Util.getCurrentOrMainLooper()); + context, + renderersFactory, + trackSelector, + loadControl, + Util.getLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -120,7 +124,7 @@ public final class ExoPlayerFactory { loadControl, bandwidthMeter, new AnalyticsCollector(Clock.DEFAULT), - Util.getCurrentOrMainLooper()); + Util.getLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -138,7 +142,7 @@ public final class ExoPlayerFactory { trackSelector, loadControl, analyticsCollector, - Util.getCurrentOrMainLooper()); + Util.getLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -216,8 +220,7 @@ public final class ExoPlayerFactory { @SuppressWarnings("deprecation") public static ExoPlayer newInstance( Context context, Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { - return newInstance( - context, renderers, trackSelector, loadControl, Util.getCurrentOrMainLooper()); + return newInstance(context, renderers, trackSelector, loadControl, Util.getLooper()); } /** @deprecated Use {@link ExoPlayer.Builder} instead. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java index cffad118ad..e690ea3626 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java @@ -437,8 +437,8 @@ import java.util.Set; (source, timeline) -> mediaSourceListInfoListener.onPlaylistUpdateRequested(); ForwardingEventListener eventListener = new ForwardingEventListener(holder); childSources.put(holder, new MediaSourceAndListener(mediaSource, caller, eventListener)); - mediaSource.addEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); - mediaSource.addDrmEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); + mediaSource.addEventListener(Util.createHandler(), eventListener); + mediaSource.addDrmEventListener(Util.createHandler(), eventListener); mediaSource.prepareSource(caller, mediaTransferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index db2602ea85..d1f0cfc798 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -199,7 +199,7 @@ public class SimpleExoPlayer extends BasePlayer this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; this.analyticsCollector = analyticsCollector; - looper = Util.getCurrentOrMainLooper(); + looper = Util.getLooper(); audioAttributes = AudioAttributes.DEFAULT; wakeMode = C.WAKE_MODE_NONE; videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java index c9c78a7422..991ed9ee97 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java @@ -65,7 +65,7 @@ public final class AudioCapabilitiesReceiver { context = context.getApplicationContext(); this.context = context; this.listener = Assertions.checkNotNull(listener); - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = new Handler(Util.getLooper()); receiver = Util.SDK_INT >= 21 ? new HdmiAudioPlugBroadcastReceiver() : null; Uri externalSurroundSoundUri = AudioCapabilities.getExternalSurroundSoundGlobalSettingUri(); externalSurroundSoundSettingObserver = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 51b939f6ce..11933e7834 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -156,7 +156,7 @@ public final class DownloadHelper { public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { Renderer[] renderers = renderersFactory.createRenderers( - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), new VideoRendererEventListener() {}, new AudioRendererEventListener() {}, (cues) -> {}, @@ -501,7 +501,7 @@ public final class DownloadHelper { this.rendererCapabilities = rendererCapabilities; this.scratchSet = new SparseIntArray(); trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); - callbackHandler = Util.createHandlerForCurrentOrMainLooper(); + callbackHandler = new Handler(Util.getLooper()); window = new Timeline.Window(); } @@ -970,8 +970,7 @@ public final class DownloadHelper { allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); pendingMediaPeriods = new ArrayList<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler downloadThreadHandler = - Util.createHandlerForCurrentOrMainLooper(this::handleDownloadHelperCallbackMessage); + Handler downloadThreadHandler = Util.createHandler(this::handleDownloadHelperCallbackMessage); this.downloadHelperHandler = downloadThreadHandler; mediaSourceThread = new HandlerThread("ExoPlayer:DownloadHelper"); mediaSourceThread.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 50df4a0e8a..12f8182980 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -228,7 +228,7 @@ public final class DownloadManager { listeners = new CopyOnWriteArraySet<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler mainHandler = Util.createHandlerForCurrentOrMainLooper(this::handleMainMessage); + Handler mainHandler = Util.createHandler(this::handleMainMessage); this.applicationHandler = mainHandler; HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager"); internalThread.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 527c51ea83..a0c08071db 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -950,7 +950,7 @@ public abstract class DownloadService extends Service { // DownloadService.getForegroundNotification, and concrete subclass implementations may // not anticipate the possibility of this method being called before their onCreate // implementation has finished executing. - Util.createHandlerForCurrentOrMainLooper() + Util.createHandler() .postAtFrontOfQueue( () -> downloadService.notifyDownloads(downloadManager.getCurrentDownloads())); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index f0a9ae3efc..849511ef3f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -71,7 +71,7 @@ public final class RequirementsWatcher { this.context = context.getApplicationContext(); this.listener = listener; this.requirements = requirements; - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = new Handler(Util.getLooper()); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java index 6693e53abe..b742d3b431 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -48,7 +48,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @CallSuper protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; - eventHandler = Util.createHandlerForCurrentOrMainLooper(); + eventHandler = Util.createHandler(); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 25283a0ecf..d879671c83 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -192,7 +192,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandler(); sampleQueueTrackIds = new TrackId[0]; sampleQueues = new SampleQueue[0]; pendingResetPositionUs = C.TIME_UNSET; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 3688c63ec1..27df9a66f3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -336,7 +336,7 @@ public final class AdsMediaSource extends CompositeMediaSource { * events on the external event listener thread. */ public ComponentListener() { - playerHandler = Util.createHandlerForCurrentOrMainLooper(); + playerHandler = Util.createHandler(); } /** Releases the component listener. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 814937717e..aefd52ab11 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1763,7 +1763,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final Handler handler; public OnFrameRenderedListenerV23(MediaCodec codec) { - handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); + handler = Util.createHandler(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index 4f9331be62..d3e85233e9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -599,7 +599,7 @@ public final class ClippingMediaSourceTest { testRunner.runOnPlaybackThread( () -> clippingMediaSource.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), new MediaSourceEventListener() { @Override public void onDownstreamFormatChanged( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index 90e1eed47f..cf2e3e879d 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -412,9 +412,7 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), - runnableInvoked::countDown)); + createFakeMediaSource(), Util.createHandler(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -430,7 +428,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -448,7 +446,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSource( /* index */ 0, createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -466,7 +464,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( /* index */ 0, Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -483,9 +481,7 @@ public final class ConcatenatingMediaSourceTest { () -> { mediaSource.addMediaSource(createFakeMediaSource()); mediaSource.removeMediaSource( - /* index */ 0, - Util.createHandlerForCurrentOrMainLooper(), - runnableInvoked::countDown); + /* index */ 0, Util.createHandler(), runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -503,10 +499,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()})); mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ - 0, - Util.createHandlerForCurrentOrMainLooper(), - runnableInvoked::countDown); + /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -523,9 +516,7 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), - timelineGrabber)); + createFakeMediaSource(), Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -544,7 +535,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -562,10 +553,7 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - /* index */ 0, - createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), - timelineGrabber)); + /* index */ 0, createFakeMediaSource(), Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -585,7 +573,7 @@ public final class ConcatenatingMediaSourceTest { /* index */ 0, Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -605,8 +593,7 @@ public final class ConcatenatingMediaSourceTest { final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); dummyMainThread.runOnMainThread( () -> - mediaSource.removeMediaSource( - /* index */ 0, Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); + mediaSource.removeMediaSource(/* index */ 0, Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(0); } finally { @@ -630,10 +617,7 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ - 0, - Util.createHandlerForCurrentOrMainLooper(), - timelineGrabber)); + /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); } finally { @@ -654,7 +638,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* currentIndex= */ 0, /* newIndex= */ 1, - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), callbackCalledCondition::countDown); mediaSource.releaseSource(caller); }); @@ -906,8 +890,7 @@ public final class ConcatenatingMediaSourceTest { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( - () -> mediaSource.clear(Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); + dummyMainThread.runOnMainThread(() -> mediaSource.clear(Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.isEmpty()).isTrue(); @@ -1059,7 +1042,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 0), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -1079,7 +1062,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 3), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandler(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(0); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java index debf839a43..8d110a8776 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java @@ -65,9 +65,7 @@ public class MediaSourceEventDispatcherTest { @Test public void listenerReceivesEventPopulatedWithMediaPeriodInfo() { eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); @@ -78,13 +76,9 @@ public class MediaSourceEventDispatcherTest { @Test public void sameListenerObjectRegisteredTwiceOnlyReceivesEventsOnce() { eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); @@ -160,9 +154,7 @@ public class MediaSourceEventDispatcherTest { @Test public void listenersAreCopiedToNewDispatcher() { eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); MediaSource.MediaPeriodId newPeriodId = new MediaSource.MediaPeriodId("different uid"); MediaSourceEventDispatcher newEventDispatcher = @@ -178,9 +170,7 @@ public class MediaSourceEventDispatcherTest { @Test public void removingListenerStopsEventDispatch() { eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( @@ -192,9 +182,7 @@ public class MediaSourceEventDispatcherTest { @Test public void removingListenerWithDifferentTypeToRegistrationDoesntRemove() { eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaAndDrmEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaAndDrmEventListener, MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaAndDrmEventListener, DrmSessionEventListener.class); eventDispatcher.dispatch( @@ -207,13 +195,9 @@ public class MediaSourceEventDispatcherTest { public void listenersAreCountedBasedOnListenerAndType() { // Add the listener twice and remove it once. eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), - mediaSourceEventListener, - MediaSourceEventListener.class); + Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 103b689dc2..747a24ca63 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -680,7 +680,7 @@ public final class DashMediaSource extends BaseMediaSource { } else { dataSource = manifestDataSourceFactory.createDataSource(); loader = new Loader("Loader:DashMediaSource"); - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandler(); startLoadingManifest(); } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index fed5ab74f5..7888841e23 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -105,7 +105,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { this.allocator = allocator; manifestPublishTimeToExpiryTimeUs = new TreeMap<>(); - handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); + handler = Util.createHandler(/* callback= */ this); decoder = new EventMessageDecoder(); lastLoadedChunkEndTimeUs = C.TIME_UNSET; lastLoadedChunkEndTimeBeforeRefreshUs = C.TIME_UNSET; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 579af21bc4..979b24f939 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -227,7 +227,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @SuppressWarnings("nullness:methodref.receiver.bound.invalid") Runnable onTracksEndedRunnable = this::onTracksEnded; this.onTracksEndedRunnable = onTracksEndedRunnable; - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandler(); lastSeekPositionUs = positionUs; pendingResetPositionUs = positionUs; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index 2806a0bdd4..d43284a211 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -121,7 +121,7 @@ public final class DefaultHlsPlaylistTracker Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener primaryPlaylistListener) { - this.playlistRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); + this.playlistRefreshHandler = Util.createHandler(); this.eventDispatcher = eventDispatcher; this.primaryPlaylistListener = primaryPlaylistListener; ParsingLoadable masterPlaylistLoadable = diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 9f63a54650..6b9a00b486 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -620,7 +620,7 @@ public final class SsMediaSource extends BaseMediaSource manifestDataSource = manifestDataSourceFactory.createDataSource(); manifestLoader = new Loader("Loader:Manifest"); manifestLoaderErrorThrower = manifestLoader; - manifestRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); + manifestRefreshHandler = Util.createHandler(); startLoadingManifest(); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index 5b8d501d00..8fe58aa45b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -602,7 +602,7 @@ public abstract class Action { } else { message.setPosition(positionMs); } - message.setHandler(Util.createHandlerForCurrentOrMainLooper()); + message.setHandler(Util.createHandler()); message.setDeleteAfterDelivery(deleteAfterDelivery); message.send(); } @@ -684,7 +684,7 @@ public abstract class Action { @Nullable Surface surface, HandlerWrapper handler, @Nullable ActionNode nextAction) { - Handler testThreadHandler = Util.createHandlerForCurrentOrMainLooper(); + Handler testThreadHandler = Util.createHandler(); // Schedule a message on the playback thread to ensure the player is paused immediately. player .createMessage( @@ -1048,7 +1048,7 @@ public abstract class Action { player .createMessage( (type, data) -> nextAction.schedule(player, trackSelector, surface, handler)) - .setHandler(Util.createHandlerForCurrentOrMainLooper()) + .setHandler(Util.createHandler()) .send(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index d8dabf05b0..ea3c74f26f 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -137,8 +137,7 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { player.addAnalyticsListener(this); player.addAnalyticsListener(new EventLogger(trackSelector, tag)); // Schedule any pending actions. - actionHandler = - Clock.DEFAULT.createHandler(Util.getCurrentOrMainLooper(), /* callback= */ null); + actionHandler = Clock.DEFAULT.createHandler(Util.getLooper(), /* callback= */ null); if (pendingSchedule != null) { pendingSchedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null); pendingSchedule = null; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index 4e3d15b43f..35fd2d7f0e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -162,7 +162,7 @@ public class FakeMediaPeriod implements MediaPeriod { /* mediaEndTimeUs = */ C.TIME_UNSET); prepareCallback = callback; if (deferOnPrepared) { - playerHandler = Util.createHandlerForCurrentOrMainLooper(); + playerHandler = Util.createHandler(); } else { finishPreparation(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index ded1da49b9..2c5a471c58 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -176,7 +176,7 @@ public class FakeMediaSource extends BaseMediaSource { drmSessionManager.prepare(); preparedSource = true; releasedSource = false; - sourceInfoRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); + sourceInfoRefreshHandler = Util.createHandler(); if (timeline != null) { finishSourcePreparation(/* sendManifestLoadEvents= */ true); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java index 548c0a0ccf..139088aeb6 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java @@ -490,7 +490,7 @@ public class TestExoPlayer { AtomicBoolean receivedMessageCallback = new AtomicBoolean(false); player .createMessage((type, data) -> receivedMessageCallback.set(true)) - .setHandler(Util.createHandlerForCurrentOrMainLooper()) + .setHandler(Util.createHandler()) .send(); runMainLooperUntil(receivedMessageCallback::get); } From 457b21556557f93540cd7ab43384d8a55eb61fd7 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 19 Jun 2020 12:18:04 +0100 Subject: [PATCH 0515/1052] Use experimental release timeout in setForgroundMode(false). The setForeground mode method blocks in the same way as release and should use the same timeout if configured. In case the method runs into the timeout, a player error is reported. PiperOrigin-RevId: 317283808 --- .../android/exoplayer2/ExoPlayerImpl.java | 9 +- .../exoplayer2/ExoPlayerImplInternal.java | 90 ++++++++----------- 2 files changed, 46 insertions(+), 53 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 26357a18dc..47ee6b062c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -659,7 +659,14 @@ import java.util.concurrent.TimeoutException; public void setForegroundMode(boolean foregroundMode) { if (this.foregroundMode != foregroundMode) { this.foregroundMode = foregroundMode; - internalPlayer.setForegroundMode(foregroundMode); + if (!internalPlayer.setForegroundMode(foregroundMode)) { + notifyListeners( + listener -> + listener.onPlayerError( + ExoPlaybackException.createForUnexpected( + new RuntimeException( + new TimeoutException("Setting foreground mode timed out."))))); + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 53c8a5d080..f7abd4dd9d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -43,6 +43,7 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.HandlerWrapper; import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Supplier; import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -296,29 +297,24 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.obtainMessage(MSG_SEND_MESSAGE, message).sendToTarget(); } - public synchronized void setForegroundMode(boolean foregroundMode) { + public synchronized boolean setForegroundMode(boolean foregroundMode) { if (released || !internalPlaybackThread.isAlive()) { - return; + return true; } if (foregroundMode) { handler.obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 1, 0).sendToTarget(); + return true; } else { AtomicBoolean processedFlag = new AtomicBoolean(); handler .obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 0, 0, processedFlag) .sendToTarget(); - boolean wasInterrupted = false; - while (!processedFlag.get()) { - try { - wait(); - } catch (InterruptedException e) { - wasInterrupted = true; - } - } - if (wasInterrupted) { - // Restore the interrupted status. - Thread.currentThread().interrupt(); + if (releaseTimeoutMs > 0) { + waitUninterruptibly(/* condition= */ processedFlag::get, releaseTimeoutMs); + } else { + waitUninterruptibly(/* condition= */ processedFlag::get); } + return processedFlag.get(); } } @@ -328,16 +324,11 @@ import java.util.concurrent.atomic.AtomicBoolean; } handler.sendEmptyMessage(MSG_RELEASE); - try { - if (releaseTimeoutMs > 0) { - waitUntilReleased(releaseTimeoutMs); - } else { - waitUntilReleased(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + if (releaseTimeoutMs > 0) { + waitUninterruptibly(/* condition= */ () -> released, releaseTimeoutMs); + } else { + waitUninterruptibly(/* condition= */ () -> released); } - return released; } @@ -505,59 +496,54 @@ import java.util.concurrent.atomic.AtomicBoolean; // Private methods. /** - * Blocks the current thread until {@link #releaseInternal()} is executed on the playback Thread. + * Blocks the current thread until a condition becomes true. * - *

          If the current thread is interrupted while waiting for {@link #releaseInternal()} to - * complete, this method will delay throwing the {@link InterruptedException} to ensure that the - * underlying resources have been released, and will an {@link InterruptedException} after - * {@link #releaseInternal()} is complete. + *

          If the current thread is interrupted while waiting for the condition to become true, this + * method will restore the interrupt after the condition became true. * - * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for - * {@link #releaseInternal()} to complete. + * @param condition The condition. */ - private synchronized void waitUntilReleased() throws InterruptedException { - InterruptedException interruptedException = null; - while (!released) { + private synchronized void waitUninterruptibly(Supplier condition) { + boolean wasInterrupted = false; + while (!condition.get()) { try { wait(); } catch (InterruptedException e) { - interruptedException = e; + wasInterrupted = true; } } - - if (interruptedException != null) { - throw interruptedException; + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); } } /** - * Blocks the current thread until {@link #releaseInternal()} is performed on the playback Thread - * or the specified amount of time has elapsed. + * Blocks the current thread until a condition becomes true or the specified amount of time has + * elapsed. * - *

          If the current thread is interrupted while waiting for {@link #releaseInternal()} to - * complete, this method will delay throwing the {@link InterruptedException} to ensure that the - * underlying resources have been released or the operation timed out, and will throw an {@link - * InterruptedException} afterwards. + *

          If the current thread is interrupted while waiting for the condition to become true, this + * method will restore the interrupt after the condition became true or the operation times + * out. * - * @param timeoutMs the time in milliseconds to wait for {@link #releaseInternal()} to complete. - * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for - * {@link #releaseInternal()} to complete. + * @param condition The condition. + * @param timeoutMs The time in milliseconds to wait for the condition to become true. */ - private synchronized void waitUntilReleased(long timeoutMs) throws InterruptedException { + private synchronized void waitUninterruptibly(Supplier condition, long timeoutMs) { long deadlineMs = clock.elapsedRealtime() + timeoutMs; long remainingMs = timeoutMs; - InterruptedException interruptedException = null; - while (!released && remainingMs > 0) { + boolean wasInterrupted = false; + while (!condition.get() && remainingMs > 0) { try { wait(remainingMs); } catch (InterruptedException e) { - interruptedException = e; + wasInterrupted = true; } remainingMs = deadlineMs - clock.elapsedRealtime(); } - - if (interruptedException != null) { - throw interruptedException; + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); } } From a8bf7e217b569898dcd13cb8f6d85cbabc470098 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 19 Jun 2020 17:31:38 +0100 Subject: [PATCH 0516/1052] Fix init data loading for non-reused extractors PiperOrigin-RevId: 317322247 --- .../hls/BundledHlsMediaChunkExtractor.java | 15 ++++++---- .../exoplayer2/source/hls/HlsMediaChunk.java | 30 +++++++++++-------- .../source/hls/HlsMediaChunkExtractor.java | 9 ++++-- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java index 4fd77135ab..c5a496c60d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; import com.google.android.exoplayer2.extractor.ts.Ac4Extractor; import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.IOException; @@ -75,11 +76,13 @@ public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtract } @Override - public HlsMediaChunkExtractor reuseOrRecreate() { - if (extractor instanceof TsExtractor || extractor instanceof FragmentedMp4Extractor) { - // We can reuse this instance. - return this; - } + public boolean isReusable() { + return extractor instanceof TsExtractor || extractor instanceof FragmentedMp4Extractor; + } + + @Override + public HlsMediaChunkExtractor recreate() { + Assertions.checkState(!isReusable()); Extractor newExtractorInstance; if (extractor instanceof WebvttExtractor) { newExtractorInstance = new WebvttExtractor(masterPlaylistFormat.language, timestampAdjuster); @@ -93,7 +96,7 @@ public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtract newExtractorInstance = new Mp3Extractor(); } else { throw new IllegalStateException( - "Unexpected previousExtractor type: " + extractor.getClass().getSimpleName()); + "Unexpected extractor type for recreation: " + extractor.getClass().getSimpleName()); } return new BundledHlsMediaChunkExtractor( newExtractorInstance, masterPlaylistFormat, timestampAdjuster); diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 3dec4fafd9..687f7f8ccb 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -134,10 +134,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; boolean shouldSpliceIn; ImmutableMap sampleQueueDiscardFromIndices = ImmutableMap.of(); if (previousChunk != null) { + boolean isFollowingChunk = + playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted; id3Decoder = previousChunk.id3Decoder; scratchId3Data = previousChunk.scratchId3Data; boolean canContinueWithoutSplice = - (playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted) + isFollowingChunk || (mediaPlaylist.hasIndependentSegments && segmentStartTimeInPeriodUs >= previousChunk.endTimeUs); shouldSpliceIn = !canContinueWithoutSplice; @@ -145,8 +147,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueueDiscardFromIndices = previousChunk.sampleQueueDiscardFromIndices; } previousExtractor = - previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber - && !shouldSpliceIn + isFollowingChunk + && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber ? previousChunk.extractor : null; } else { @@ -334,9 +336,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public void load() throws IOException { // output == null means init() hasn't been called. Assertions.checkNotNull(output); - if (extractor == null && previousExtractor != null) { - extractor = previousExtractor.reuseOrRecreate(); - initDataLoadRequired = extractor != previousExtractor; + if (extractor == null && previousExtractor != null && previousExtractor.isReusable()) { + extractor = previousExtractor; + initDataLoadRequired = false; } maybeLoadInitData(); if (!loadCanceled) { @@ -426,13 +428,15 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; extractorInput.resetPeekPosition(); extractor = - extractorFactory.createExtractor( - dataSpec.uri, - trackFormat, - muxedCaptionFormats, - timestampAdjuster, - dataSource.getResponseHeaders(), - extractorInput); + previousExtractor != null + ? previousExtractor.recreate() + : extractorFactory.createExtractor( + dataSpec.uri, + trackFormat, + muxedCaptionFormats, + timestampAdjuster, + dataSource.getResponseHeaders(), + extractorInput); if (extractor.isPackedAudioExtractor()) { output.setSampleOffsetUs( id3Timestamp != C.TIME_UNSET diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java index 55f69b7e6c..0ca5c5d0ad 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java @@ -51,9 +51,12 @@ public interface HlsMediaChunkExtractor { /** Returns whether this is a packed audio extractor, as defined in RFC 8216, Section 3.4. */ boolean isPackedAudioExtractor(); + /** Returns whether this instance can be used for extracting multiple continuous segments. */ + boolean isReusable(); + /** - * If this instance can be used for extracting multiple continuous segments, returns itself. - * Otherwise, returns a new instance for extracting the same type of media. + * Returns a new instance for extracting the same type of media as this one. Can only be called on + * instances that are not {@link #isReusable() reusable}. */ - HlsMediaChunkExtractor reuseOrRecreate(); + HlsMediaChunkExtractor recreate(); } From 7d66865d208c6ee9da023abb8d8619cd9ec312e3 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 19 Jun 2020 18:21:42 +0100 Subject: [PATCH 0517/1052] Rollback of https://github.com/google/ExoPlayer/commit/63ae4cc54bc58303faf15a0dc97017792b0de6f2 *** Original commit *** Rollback of https://github.com/google/ExoPlayer/commit/6ae472243f16d1f075328a779f3d4b46e180b76d *** Original commit *** PiperOrigin-RevId: 317331407 --- .../ext/leanback/LeanbackPlayerAdapter.java | 2 +- .../mediasession/MediaSessionConnector.java | 2 +- .../google/android/exoplayer2/util/Util.java | 13 ++--- .../google/android/exoplayer2/ExoPlayer.java | 2 +- .../android/exoplayer2/ExoPlayerFactory.java | 13 ++--- .../android/exoplayer2/MediaSourceList.java | 4 +- .../android/exoplayer2/SimpleExoPlayer.java | 2 +- .../audio/AudioCapabilitiesReceiver.java | 2 +- .../exoplayer2/offline/DownloadHelper.java | 7 +-- .../exoplayer2/offline/DownloadManager.java | 2 +- .../exoplayer2/offline/DownloadService.java | 2 +- .../scheduler/RequirementsWatcher.java | 2 +- .../source/CompositeMediaSource.java | 2 +- .../source/ProgressiveMediaPeriod.java | 2 +- .../exoplayer2/source/ads/AdsMediaSource.java | 2 +- .../video/MediaCodecVideoRenderer.java | 2 +- .../source/ClippingMediaSourceTest.java | 2 +- .../source/ConcatenatingMediaSourceTest.java | 49 +++++++++++++------ .../util/MediaSourceEventDispatcherTest.java | 32 +++++++++--- .../source/dash/DashMediaSource.java | 2 +- .../source/dash/PlayerEmsgHandler.java | 2 +- .../source/hls/HlsSampleStreamWrapper.java | 2 +- .../playlist/DefaultHlsPlaylistTracker.java | 2 +- .../source/smoothstreaming/SsMediaSource.java | 2 +- .../android/exoplayer2/testutil/Action.java | 6 +-- .../exoplayer2/testutil/ExoHostedTest.java | 3 +- .../exoplayer2/testutil/FakeMediaPeriod.java | 2 +- .../exoplayer2/testutil/FakeMediaSource.java | 2 +- .../exoplayer2/testutil/TestExoPlayer.java | 2 +- 29 files changed, 101 insertions(+), 68 deletions(-) diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java index e385cd52e9..6538160b8b 100644 --- a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java +++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java @@ -72,7 +72,7 @@ public final class LeanbackPlayerAdapter extends PlayerAdapter implements Runnab this.context = context; this.player = player; this.updatePeriodMs = updatePeriodMs; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); componentListener = new ComponentListener(); controlDispatcher = new DefaultControlDispatcher(); } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index b74ad9701f..f3edfa3545 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -437,7 +437,7 @@ public final class MediaSessionConnector { */ public MediaSessionConnector(MediaSessionCompat mediaSession) { this.mediaSession = mediaSession; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); componentListener = new ComponentListener(); commandReceivers = new ArrayList<>(); customCommandReceivers = new ArrayList<>(); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 09303c4a9c..96c2d3622a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -399,8 +399,8 @@ public final class Util { *

          If the current thread doesn't have a {@link Looper}, the application's main thread {@link * Looper} is used. */ - public static Handler createHandler() { - return createHandler(/* callback= */ null); + public static Handler createHandlerForCurrentOrMainLooper() { + return createHandlerForCurrentOrMainLooper(/* callback= */ null); } /** @@ -416,8 +416,9 @@ public final class Util { * callback is required. * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. */ - public static Handler createHandler(@Nullable Handler.@UnknownInitialization Callback callback) { - return createHandler(getLooper(), callback); + public static Handler createHandlerForCurrentOrMainLooper( + @Nullable Handler.@UnknownInitialization Callback callback) { + return createHandler(getCurrentOrMainLooper(), callback); } /** @@ -441,8 +442,8 @@ public final class Util { * Returns the {@link Looper} associated with the current thread, or the {@link Looper} of the * application's main thread if the current thread doesn't have a {@link Looper}. */ - public static Looper getLooper() { - Looper myLooper = Looper.myLooper(); + public static Looper getCurrentOrMainLooper() { + @Nullable Looper myLooper = Looper.myLooper(); return myLooper != null ? myLooper : Looper.getMainLooper(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b4cd9a399d..9990e77f3a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -209,7 +209,7 @@ public interface ExoPlayer extends Player { this.mediaSourceFactory = mediaSourceFactory; this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); useLazyPreparation = true; seekParameters = SeekParameters.DEFAULT; clock = Clock.DEFAULT; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index 2c07593aaa..dcdce89489 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -101,11 +101,7 @@ public final class ExoPlayerFactory { TrackSelector trackSelector, LoadControl loadControl) { return newSimpleInstance( - context, - renderersFactory, - trackSelector, - loadControl, - Util.getLooper()); + context, renderersFactory, trackSelector, loadControl, Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -124,7 +120,7 @@ public final class ExoPlayerFactory { loadControl, bandwidthMeter, new AnalyticsCollector(Clock.DEFAULT), - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -142,7 +138,7 @@ public final class ExoPlayerFactory { trackSelector, loadControl, analyticsCollector, - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -220,7 +216,8 @@ public final class ExoPlayerFactory { @SuppressWarnings("deprecation") public static ExoPlayer newInstance( Context context, Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { - return newInstance(context, renderers, trackSelector, loadControl, Util.getLooper()); + return newInstance( + context, renderers, trackSelector, loadControl, Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link ExoPlayer.Builder} instead. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java index e690ea3626..cffad118ad 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java @@ -437,8 +437,8 @@ import java.util.Set; (source, timeline) -> mediaSourceListInfoListener.onPlaylistUpdateRequested(); ForwardingEventListener eventListener = new ForwardingEventListener(holder); childSources.put(holder, new MediaSourceAndListener(mediaSource, caller, eventListener)); - mediaSource.addEventListener(Util.createHandler(), eventListener); - mediaSource.addDrmEventListener(Util.createHandler(), eventListener); + mediaSource.addEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); + mediaSource.addDrmEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); mediaSource.prepareSource(caller, mediaTransferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index d1f0cfc798..db2602ea85 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -199,7 +199,7 @@ public class SimpleExoPlayer extends BasePlayer this.loadControl = loadControl; this.bandwidthMeter = bandwidthMeter; this.analyticsCollector = analyticsCollector; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); audioAttributes = AudioAttributes.DEFAULT; wakeMode = C.WAKE_MODE_NONE; videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java index 991ed9ee97..c9c78a7422 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java @@ -65,7 +65,7 @@ public final class AudioCapabilitiesReceiver { context = context.getApplicationContext(); this.context = context; this.listener = Assertions.checkNotNull(listener); - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); receiver = Util.SDK_INT >= 21 ? new HdmiAudioPlugBroadcastReceiver() : null; Uri externalSurroundSoundUri = AudioCapabilities.getExternalSurroundSoundGlobalSettingUri(); externalSurroundSoundSettingObserver = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 11933e7834..51b939f6ce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -156,7 +156,7 @@ public final class DownloadHelper { public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { Renderer[] renderers = renderersFactory.createRenderers( - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), new VideoRendererEventListener() {}, new AudioRendererEventListener() {}, (cues) -> {}, @@ -501,7 +501,7 @@ public final class DownloadHelper { this.rendererCapabilities = rendererCapabilities; this.scratchSet = new SparseIntArray(); trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); - callbackHandler = new Handler(Util.getLooper()); + callbackHandler = Util.createHandlerForCurrentOrMainLooper(); window = new Timeline.Window(); } @@ -970,7 +970,8 @@ public final class DownloadHelper { allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); pendingMediaPeriods = new ArrayList<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler downloadThreadHandler = Util.createHandler(this::handleDownloadHelperCallbackMessage); + Handler downloadThreadHandler = + Util.createHandlerForCurrentOrMainLooper(this::handleDownloadHelperCallbackMessage); this.downloadHelperHandler = downloadThreadHandler; mediaSourceThread = new HandlerThread("ExoPlayer:DownloadHelper"); mediaSourceThread.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 12f8182980..50df4a0e8a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -228,7 +228,7 @@ public final class DownloadManager { listeners = new CopyOnWriteArraySet<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler mainHandler = Util.createHandler(this::handleMainMessage); + Handler mainHandler = Util.createHandlerForCurrentOrMainLooper(this::handleMainMessage); this.applicationHandler = mainHandler; HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager"); internalThread.start(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index a0c08071db..527c51ea83 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -950,7 +950,7 @@ public abstract class DownloadService extends Service { // DownloadService.getForegroundNotification, and concrete subclass implementations may // not anticipate the possibility of this method being called before their onCreate // implementation has finished executing. - Util.createHandler() + Util.createHandlerForCurrentOrMainLooper() .postAtFrontOfQueue( () -> downloadService.notifyDownloads(downloadManager.getCurrentDownloads())); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index 849511ef3f..f0a9ae3efc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -71,7 +71,7 @@ public final class RequirementsWatcher { this.context = context.getApplicationContext(); this.listener = listener; this.requirements = requirements; - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java index b742d3b431..6693e53abe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -48,7 +48,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @CallSuper protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; - eventHandler = Util.createHandler(); + eventHandler = Util.createHandlerForCurrentOrMainLooper(); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index d879671c83..25283a0ecf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -192,7 +192,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); sampleQueueTrackIds = new TrackId[0]; sampleQueues = new SampleQueue[0]; pendingResetPositionUs = C.TIME_UNSET; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 27df9a66f3..3688c63ec1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -336,7 +336,7 @@ public final class AdsMediaSource extends CompositeMediaSource { * events on the external event listener thread. */ public ComponentListener() { - playerHandler = Util.createHandler(); + playerHandler = Util.createHandlerForCurrentOrMainLooper(); } /** Releases the component listener. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index aefd52ab11..814937717e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1763,7 +1763,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final Handler handler; public OnFrameRenderedListenerV23(MediaCodec codec) { - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index d3e85233e9..4f9331be62 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -599,7 +599,7 @@ public final class ClippingMediaSourceTest { testRunner.runOnPlaybackThread( () -> clippingMediaSource.addEventListener( - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), new MediaSourceEventListener() { @Override public void onDownstreamFormatChanged( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index cf2e3e879d..90e1eed47f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -412,7 +412,9 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), Util.createHandler(), runnableInvoked::countDown)); + createFakeMediaSource(), + Util.createHandlerForCurrentOrMainLooper(), + runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -428,7 +430,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -446,7 +448,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSource( /* index */ 0, createFakeMediaSource(), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -464,7 +466,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( /* index */ 0, Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -481,7 +483,9 @@ public final class ConcatenatingMediaSourceTest { () -> { mediaSource.addMediaSource(createFakeMediaSource()); mediaSource.removeMediaSource( - /* index */ 0, Util.createHandler(), runnableInvoked::countDown); + /* index */ 0, + Util.createHandlerForCurrentOrMainLooper(), + runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -499,7 +503,10 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()})); mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), runnableInvoked::countDown); + /* fromIndex */ 1, /* toIndex */ + 0, + Util.createHandlerForCurrentOrMainLooper(), + runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -516,7 +523,9 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), Util.createHandler(), timelineGrabber)); + createFakeMediaSource(), + Util.createHandlerForCurrentOrMainLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -535,7 +544,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -553,7 +562,10 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - /* index */ 0, createFakeMediaSource(), Util.createHandler(), timelineGrabber)); + /* index */ 0, + createFakeMediaSource(), + Util.createHandlerForCurrentOrMainLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -573,7 +585,7 @@ public final class ConcatenatingMediaSourceTest { /* index */ 0, Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -593,7 +605,8 @@ public final class ConcatenatingMediaSourceTest { final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); dummyMainThread.runOnMainThread( () -> - mediaSource.removeMediaSource(/* index */ 0, Util.createHandler(), timelineGrabber)); + mediaSource.removeMediaSource( + /* index */ 0, Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(0); } finally { @@ -617,7 +630,10 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), timelineGrabber)); + /* fromIndex */ 1, /* toIndex */ + 0, + Util.createHandlerForCurrentOrMainLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); } finally { @@ -638,7 +654,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* currentIndex= */ 0, /* newIndex= */ 1, - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), callbackCalledCondition::countDown); mediaSource.releaseSource(caller); }); @@ -890,7 +906,8 @@ public final class ConcatenatingMediaSourceTest { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread(() -> mediaSource.clear(Util.createHandler(), timelineGrabber)); + dummyMainThread.runOnMainThread( + () -> mediaSource.clear(Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.isEmpty()).isTrue(); @@ -1042,7 +1059,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 0), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -1062,7 +1079,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 3), - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(0); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java index 8d110a8776..debf839a43 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java @@ -65,7 +65,9 @@ public class MediaSourceEventDispatcherTest { @Test public void listenerReceivesEventPopulatedWithMediaPeriodInfo() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.dispatch( MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); @@ -76,9 +78,13 @@ public class MediaSourceEventDispatcherTest { @Test public void sameListenerObjectRegisteredTwiceOnlyReceivesEventsOnce() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.dispatch( MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); @@ -154,7 +160,9 @@ public class MediaSourceEventDispatcherTest { @Test public void listenersAreCopiedToNewDispatcher() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); MediaSource.MediaPeriodId newPeriodId = new MediaSource.MediaPeriodId("different uid"); MediaSourceEventDispatcher newEventDispatcher = @@ -170,7 +178,9 @@ public class MediaSourceEventDispatcherTest { @Test public void removingListenerStopsEventDispatch() { eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( @@ -182,7 +192,9 @@ public class MediaSourceEventDispatcherTest { @Test public void removingListenerWithDifferentTypeToRegistrationDoesntRemove() { eventDispatcher.addEventListener( - Util.createHandler(), mediaAndDrmEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaAndDrmEventListener, + MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaAndDrmEventListener, DrmSessionEventListener.class); eventDispatcher.dispatch( @@ -195,9 +207,13 @@ public class MediaSourceEventDispatcherTest { public void listenersAreCountedBasedOnListenerAndType() { // Add the listener twice and remove it once. eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); + Util.createHandlerForCurrentOrMainLooper(), + mediaSourceEventListener, + MediaSourceEventListener.class); eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); eventDispatcher.dispatch( diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 747a24ca63..103b689dc2 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -680,7 +680,7 @@ public final class DashMediaSource extends BaseMediaSource { } else { dataSource = manifestDataSourceFactory.createDataSource(); loader = new Loader("Loader:DashMediaSource"); - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); startLoadingManifest(); } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index 7888841e23..fed5ab74f5 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -105,7 +105,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { this.allocator = allocator; manifestPublishTimeToExpiryTimeUs = new TreeMap<>(); - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); decoder = new EventMessageDecoder(); lastLoadedChunkEndTimeUs = C.TIME_UNSET; lastLoadedChunkEndTimeBeforeRefreshUs = C.TIME_UNSET; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 979b24f939..579af21bc4 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -227,7 +227,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @SuppressWarnings("nullness:methodref.receiver.bound.invalid") Runnable onTracksEndedRunnable = this::onTracksEnded; this.onTracksEndedRunnable = onTracksEndedRunnable; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); lastSeekPositionUs = positionUs; pendingResetPositionUs = positionUs; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index d43284a211..2806a0bdd4 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -121,7 +121,7 @@ public final class DefaultHlsPlaylistTracker Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener primaryPlaylistListener) { - this.playlistRefreshHandler = Util.createHandler(); + this.playlistRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); this.eventDispatcher = eventDispatcher; this.primaryPlaylistListener = primaryPlaylistListener; ParsingLoadable masterPlaylistLoadable = diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 6b9a00b486..9f63a54650 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -620,7 +620,7 @@ public final class SsMediaSource extends BaseMediaSource manifestDataSource = manifestDataSourceFactory.createDataSource(); manifestLoader = new Loader("Loader:Manifest"); manifestLoaderErrorThrower = manifestLoader; - manifestRefreshHandler = Util.createHandler(); + manifestRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); startLoadingManifest(); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index 8fe58aa45b..5b8d501d00 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -602,7 +602,7 @@ public abstract class Action { } else { message.setPosition(positionMs); } - message.setHandler(Util.createHandler()); + message.setHandler(Util.createHandlerForCurrentOrMainLooper()); message.setDeleteAfterDelivery(deleteAfterDelivery); message.send(); } @@ -684,7 +684,7 @@ public abstract class Action { @Nullable Surface surface, HandlerWrapper handler, @Nullable ActionNode nextAction) { - Handler testThreadHandler = Util.createHandler(); + Handler testThreadHandler = Util.createHandlerForCurrentOrMainLooper(); // Schedule a message on the playback thread to ensure the player is paused immediately. player .createMessage( @@ -1048,7 +1048,7 @@ public abstract class Action { player .createMessage( (type, data) -> nextAction.schedule(player, trackSelector, surface, handler)) - .setHandler(Util.createHandler()) + .setHandler(Util.createHandlerForCurrentOrMainLooper()) .send(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index ea3c74f26f..d8dabf05b0 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -137,7 +137,8 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { player.addAnalyticsListener(this); player.addAnalyticsListener(new EventLogger(trackSelector, tag)); // Schedule any pending actions. - actionHandler = Clock.DEFAULT.createHandler(Util.getLooper(), /* callback= */ null); + actionHandler = + Clock.DEFAULT.createHandler(Util.getCurrentOrMainLooper(), /* callback= */ null); if (pendingSchedule != null) { pendingSchedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null); pendingSchedule = null; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index 35fd2d7f0e..4e3d15b43f 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -162,7 +162,7 @@ public class FakeMediaPeriod implements MediaPeriod { /* mediaEndTimeUs = */ C.TIME_UNSET); prepareCallback = callback; if (deferOnPrepared) { - playerHandler = Util.createHandler(); + playerHandler = Util.createHandlerForCurrentOrMainLooper(); } else { finishPreparation(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index 2c5a471c58..ded1da49b9 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -176,7 +176,7 @@ public class FakeMediaSource extends BaseMediaSource { drmSessionManager.prepare(); preparedSource = true; releasedSource = false; - sourceInfoRefreshHandler = Util.createHandler(); + sourceInfoRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); if (timeline != null) { finishSourcePreparation(/* sendManifestLoadEvents= */ true); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java index 139088aeb6..548c0a0ccf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java @@ -490,7 +490,7 @@ public class TestExoPlayer { AtomicBoolean receivedMessageCallback = new AtomicBoolean(false); player .createMessage((type, data) -> receivedMessageCallback.set(true)) - .setHandler(Util.createHandler()) + .setHandler(Util.createHandlerForCurrentOrMainLooper()) .send(); runMainLooperUntil(receivedMessageCallback::get); } From c5c4c8772871cb3e0b2159d584a4dd00a4ba4dc9 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 19 Jun 2020 18:36:20 +0100 Subject: [PATCH 0518/1052] Restrict some Handler to current Looper only. They currently fall back to the main Looper if the current thread doesn't have a Looper. All the changed Handlers are guaranteed to be created on a thread with a Looper (mostly the ExoPlayer playback Looper) and thus can make this stricter assumption. This makes it easier to reason about the code as there are no ambiguities as to which thread the Handler is running on. PiperOrigin-RevId: 317334503 --- .../google/android/exoplayer2/util/Util.java | 40 ++++++++++++++++--- .../source/CompositeMediaSource.java | 2 +- .../source/ProgressiveMediaPeriod.java | 2 +- .../exoplayer2/source/ads/AdsMediaSource.java | 2 +- .../video/MediaCodecVideoRenderer.java | 2 +- .../source/ClippingMediaSourceTest.java | 2 +- .../source/ConcatenatingMediaSourceTest.java | 36 ++++++++--------- .../source/dash/DashMediaSource.java | 2 +- .../source/dash/PlayerEmsgHandler.java | 2 +- .../source/hls/HlsSampleStreamWrapper.java | 2 +- .../playlist/DefaultHlsPlaylistTracker.java | 2 +- .../source/smoothstreaming/SsMediaSource.java | 2 +- .../exoplayer2/testutil/FakeMediaPeriod.java | 2 +- .../exoplayer2/testutil/FakeMediaSource.java | 2 +- 14 files changed, 62 insertions(+), 38 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 96c2d3622a..838de61db4 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -393,6 +393,32 @@ public final class Util { return concatenation; } + /** + * Creates a {@link Handler} on the current {@link Looper} thread. + * + * @throws IllegalStateException If the current thread doesn't have a {@link Looper}. + */ + public static Handler createHandlerForCurrentLooper() { + return createHandlerForCurrentLooper(/* callback= */ null); + } + + /** + * Creates a {@link Handler} with the specified {@link Handler.Callback} on the current {@link + * Looper} thread. + * + *

          The method accepts partially initialized objects as callback under the assumption that the + * Handler won't be used to send messages until the callback is fully initialized. + * + * @param callback A {@link Handler.Callback}. May be a partially initialized class, or null if no + * callback is required. + * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. + * @throws IllegalStateException If the current thread doesn't have a {@link Looper}. + */ + public static Handler createHandlerForCurrentLooper( + @Nullable Handler.@UnknownInitialization Callback callback) { + return createHandler(Assertions.checkStateNotNull(Looper.myLooper()), callback); + } + /** * Creates a {@link Handler} on the current {@link Looper} thread. * @@ -405,9 +431,10 @@ public final class Util { /** * Creates a {@link Handler} with the specified {@link Handler.Callback} on the current {@link - * Looper} thread. The method accepts partially initialized objects as callback under the - * assumption that the Handler won't be used to send messages until the callback is fully - * initialized. + * Looper} thread. + * + *

          The method accepts partially initialized objects as callback under the assumption that the + * Handler won't be used to send messages until the callback is fully initialized. * *

          If the current thread doesn't have a {@link Looper}, the application's main thread {@link * Looper} is used. @@ -423,9 +450,10 @@ public final class Util { /** * Creates a {@link Handler} with the specified {@link Handler.Callback} on the specified {@link - * Looper} thread. The method accepts partially initialized objects as callback under the - * assumption that the Handler won't be used to send messages until the callback is fully - * initialized. + * Looper} thread. + * + *

          The method accepts partially initialized objects as callback under the assumption that the + * Handler won't be used to send messages until the callback is fully initialized. * * @param looper A {@link Looper} to run the callback on. * @param callback A {@link Handler.Callback}. May be a partially initialized class, or null if no diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java index 6693e53abe..3d31eeba92 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -48,7 +48,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @CallSuper protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; - eventHandler = Util.createHandlerForCurrentOrMainLooper(); + eventHandler = Util.createHandlerForCurrentLooper(); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 25283a0ecf..3bfb8356a5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -192,7 +192,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandlerForCurrentLooper(); sampleQueueTrackIds = new TrackId[0]; sampleQueues = new SampleQueue[0]; pendingResetPositionUs = C.TIME_UNSET; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 3688c63ec1..ce45959325 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -336,7 +336,7 @@ public final class AdsMediaSource extends CompositeMediaSource { * events on the external event listener thread. */ public ComponentListener() { - playerHandler = Util.createHandlerForCurrentOrMainLooper(); + playerHandler = Util.createHandlerForCurrentLooper(); } /** Releases the component listener. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 814937717e..afc029b7cd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1763,7 +1763,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final Handler handler; public OnFrameRenderedListenerV23(MediaCodec codec) { - handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); + handler = Util.createHandlerForCurrentLooper(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index 4f9331be62..69b38bd2e1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -599,7 +599,7 @@ public final class ClippingMediaSourceTest { testRunner.runOnPlaybackThread( () -> clippingMediaSource.addEventListener( - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), new MediaSourceEventListener() { @Override public void onDownstreamFormatChanged( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index 90e1eed47f..b3d0d1cc14 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -413,7 +413,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.addMediaSource( createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -430,7 +430,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -448,7 +448,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSource( /* index */ 0, createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -466,7 +466,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( /* index */ 0, Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -483,9 +483,7 @@ public final class ConcatenatingMediaSourceTest { () -> { mediaSource.addMediaSource(createFakeMediaSource()); mediaSource.removeMediaSource( - /* index */ 0, - Util.createHandlerForCurrentOrMainLooper(), - runnableInvoked::countDown); + /* index */ 0, Util.createHandlerForCurrentLooper(), runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -505,7 +503,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* fromIndex */ 1, /* toIndex */ 0, - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown); }); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); @@ -523,9 +521,7 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), - timelineGrabber)); + createFakeMediaSource(), Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { @@ -544,7 +540,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSources( Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -564,7 +560,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.addMediaSource( /* index */ 0, createFakeMediaSource(), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); @@ -585,7 +581,7 @@ public final class ConcatenatingMediaSourceTest { /* index */ 0, Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -606,7 +602,7 @@ public final class ConcatenatingMediaSourceTest { dummyMainThread.runOnMainThread( () -> mediaSource.removeMediaSource( - /* index */ 0, Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); + /* index */ 0, Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(0); } finally { @@ -632,7 +628,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* fromIndex */ 1, /* toIndex */ 0, - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); @@ -654,7 +650,7 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* currentIndex= */ 0, /* newIndex= */ 1, - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), callbackCalledCondition::countDown); mediaSource.releaseSource(caller); }); @@ -907,7 +903,7 @@ public final class ConcatenatingMediaSourceTest { final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); dummyMainThread.runOnMainThread( - () -> mediaSource.clear(Util.createHandlerForCurrentOrMainLooper(), timelineGrabber)); + () -> mediaSource.clear(Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.isEmpty()).isTrue(); @@ -1059,7 +1055,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 0), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); dummyMainThread.release(); @@ -1079,7 +1075,7 @@ public final class ConcatenatingMediaSourceTest { () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 3), - Util.createHandlerForCurrentOrMainLooper(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(0); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 103b689dc2..fe2b18814b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -680,7 +680,7 @@ public final class DashMediaSource extends BaseMediaSource { } else { dataSource = manifestDataSourceFactory.createDataSource(); loader = new Loader("Loader:DashMediaSource"); - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandlerForCurrentLooper(); startLoadingManifest(); } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index fed5ab74f5..58783ad745 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -105,7 +105,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { this.allocator = allocator; manifestPublishTimeToExpiryTimeUs = new TreeMap<>(); - handler = Util.createHandlerForCurrentOrMainLooper(/* callback= */ this); + handler = Util.createHandlerForCurrentLooper(/* callback= */ this); decoder = new EventMessageDecoder(); lastLoadedChunkEndTimeUs = C.TIME_UNSET; lastLoadedChunkEndTimeBeforeRefreshUs = C.TIME_UNSET; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 579af21bc4..78c9e2796d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -227,7 +227,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @SuppressWarnings("nullness:methodref.receiver.bound.invalid") Runnable onTracksEndedRunnable = this::onTracksEnded; this.onTracksEndedRunnable = onTracksEndedRunnable; - handler = Util.createHandlerForCurrentOrMainLooper(); + handler = Util.createHandlerForCurrentLooper(); lastSeekPositionUs = positionUs; pendingResetPositionUs = positionUs; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index 2806a0bdd4..c9e92de9e2 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -121,7 +121,7 @@ public final class DefaultHlsPlaylistTracker Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener primaryPlaylistListener) { - this.playlistRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); + this.playlistRefreshHandler = Util.createHandlerForCurrentLooper(); this.eventDispatcher = eventDispatcher; this.primaryPlaylistListener = primaryPlaylistListener; ParsingLoadable masterPlaylistLoadable = diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 9f63a54650..019421b35f 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -620,7 +620,7 @@ public final class SsMediaSource extends BaseMediaSource manifestDataSource = manifestDataSourceFactory.createDataSource(); manifestLoader = new Loader("Loader:Manifest"); manifestLoaderErrorThrower = manifestLoader; - manifestRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); + manifestRefreshHandler = Util.createHandlerForCurrentLooper(); startLoadingManifest(); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index 4e3d15b43f..e83d924293 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -162,7 +162,7 @@ public class FakeMediaPeriod implements MediaPeriod { /* mediaEndTimeUs = */ C.TIME_UNSET); prepareCallback = callback; if (deferOnPrepared) { - playerHandler = Util.createHandlerForCurrentOrMainLooper(); + playerHandler = Util.createHandlerForCurrentLooper(); } else { finishPreparation(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index ded1da49b9..d4d4e76054 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -176,7 +176,7 @@ public class FakeMediaSource extends BaseMediaSource { drmSessionManager.prepare(); preparedSource = true; releasedSource = false; - sourceInfoRefreshHandler = Util.createHandlerForCurrentOrMainLooper(); + sourceInfoRefreshHandler = Util.createHandlerForCurrentLooper(); if (timeline != null) { finishSourcePreparation(/* sendManifestLoadEvents= */ true); } From dbe16cd2682a27c766b651aee9cd8562843474af Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 22 Jun 2020 08:45:35 +0100 Subject: [PATCH 0519/1052] Remove unnecessary null check PiperOrigin-RevId: 317604812 --- .../exoplayer2/source/dash/EventSampleStream.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java index 6e67be6ec5..dc70653141 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java @@ -113,15 +113,11 @@ import java.io.IOException; } int sampleIndex = currentIndex++; byte[] serializedEvent = eventMessageEncoder.encode(eventStream.events[sampleIndex]); - if (serializedEvent != null) { - buffer.ensureSpaceForWrite(serializedEvent.length); - buffer.data.put(serializedEvent); - buffer.timeUs = eventTimesUs[sampleIndex]; - buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME); - return C.RESULT_BUFFER_READ; - } else { - return C.RESULT_NOTHING_READ; - } + buffer.ensureSpaceForWrite(serializedEvent.length); + buffer.data.put(serializedEvent); + buffer.timeUs = eventTimesUs[sampleIndex]; + buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME); + return C.RESULT_BUFFER_READ; } @Override From aec5ff8be1ab0d252beeba506599201f96a465f2 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 22 Jun 2020 09:04:34 +0100 Subject: [PATCH 0520/1052] Fix incorrect rounding of ad cue points We currently get float ad cue points from IMA, but store these as longs in microseconds. The cast from double to long would take the floor of the value, which could lead to stored ad cue points being off-by-one. Use Math.round to avoid this. ImaAdsLoader also has code to map a double AdPodInfo position (which should match a cue point) onto the corresponding ad group index by searching the long ad cue points. Match the calculation used where we map float cue points, including narrowing the position to a float first to avoid regressions if IMA SDK behavior changes to represent positions in more than float precision later, and also remove the requirement that the ad positions match exactly as a defensive measure. PiperOrigin-RevId: 317607017 --- RELEASENOTES.md | 1 + demos/main/src/main/assets/media.exolist.json | 5 ++ .../ext/ima/AdPlaybackStateFactory.java | 2 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 12 ++++- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 49 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 96370e7e5b..aaf25b1cd5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -228,6 +228,7 @@ * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded on seeking to another position ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix incorrect rounding of ad cue points. ### 2.11.5 (2020-06-05) ### diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index db07652312..a1ea669f0e 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -487,6 +487,11 @@ "name": "VMAP full, empty, full midrolls", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", "ad_tag_uri": "https://vastsynthesizer.appspot.com/empty-midroll-2" + }, + { + "name": "VMAP midroll at 1765 s", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4", + "ad_tag_uri": "https://vastsynthesizer.appspot.com/midroll-large" } ] }, diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java index 3c1b6954aa..a97307a419 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java @@ -46,7 +46,7 @@ import java.util.List; if (cuePoint == -1.0) { adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; } else { - adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); + adGroupTimesUs[adGroupIndex++] = Math.round(C.MICROS_PER_SECOND * cuePoint); } } // Cue points may be out of order, so sort them. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index a8748219ef..c055fb60d2 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -343,6 +343,8 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { * milliseconds. */ private static final long THRESHOLD_AD_PRELOAD_MS = 4000; + /** The threshold below which ad cue points are treated as matching, in microseconds. */ + private static final long THRESHOLD_AD_MATCH_US = 1000; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -1261,9 +1263,15 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { } // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. - long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); + // We receive cue points from IMA SDK as floats. This code replicates the same calculation used + // to populate adGroupTimesUs (having truncated input back to float, to avoid failures if the + // behavior of the IMA SDK changes to provide greater precision in AdPodInfo). + long adPodTimeUs = + Math.round((double) ((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { - if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) { + long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; + if (adGroupTimeUs != C.TIME_END_OF_SOURCE + && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { return adGroupIndex; } } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index c3202a26be..5a59690b44 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -641,6 +641,55 @@ public final class ImaAdsLoaderTest { inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } + @Test + public void loadAd_withLargeAdCuePoint_updatesAdPlaybackStateWithLoadedAd() { + float midrollTimeSecs = 1_765f; + ImmutableList cuePoints = ImmutableList.of(midrollTimeSecs); + setupPlayback(CONTENT_TIMELINE, cuePoints); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + videoAdPlayer.loadAd( + TEST_AD_MEDIA_INFO, + new AdPodInfo() { + @Override + public int getTotalAds() { + return 1; + } + + @Override + public int getAdPosition() { + return 1; + } + + @Override + public boolean isBumper() { + return false; + } + + @Override + public double getMaxDuration() { + return 0; + } + + @Override + public int getPodIndex() { + return 0; + } + + @Override + public double getTimeOffset() { + return midrollTimeSecs; + } + }); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})); + } + private void setupPlayback(Timeline contentTimeline, List cuePoints) { setupPlayback( contentTimeline, From 6d9a1ed6398adf8601f1b3517db4659b3ed25e53 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 22 Jun 2020 09:34:31 +0100 Subject: [PATCH 0521/1052] Noop consistency fixes for extension decoders PiperOrigin-RevId: 317609986 --- .../exoplayer2/ext/av1/Gav1Decoder.java | 18 +++++++++--------- .../android/exoplayer2/ext/vp9/VpxDecoder.java | 18 +++++++++--------- extensions/vp9/src/main/jni/vpx_jni.cc | 8 +++++--- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java index 76d7ddd380..ad8c8a682c 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java @@ -84,15 +84,6 @@ import java.nio.ByteBuffer; return "libgav1"; } - /** - * Sets the output mode for frames rendered by the decoder. - * - * @param outputMode The output mode. - */ - public void setOutputMode(@C.VideoOutputMode int outputMode) { - this.outputMode = outputMode; - } - @Override protected VideoDecoderInputBuffer createInputBuffer() { return new VideoDecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); @@ -156,6 +147,15 @@ import java.nio.ByteBuffer; super.releaseOutputBuffer(buffer); } + /** + * Sets the output mode for frames rendered by the decoder. + * + * @param outputMode The output mode. + */ + public void setOutputMode(@C.VideoOutputMode int outputMode) { + this.outputMode = outputMode; + } + /** * Renders output buffer to the given surface. Must only be called when in {@link * C#VIDEO_OUTPUT_MODE_SURFACE_YUV} mode. diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java index f55e3e6f15..22086cd74d 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java @@ -87,15 +87,6 @@ import java.nio.ByteBuffer; return "libvpx" + VpxLibrary.getVersion(); } - /** - * Sets the output mode for frames rendered by the decoder. - * - * @param outputMode The output mode. - */ - public void setOutputMode(@C.VideoOutputMode int outputMode) { - this.outputMode = outputMode; - } - @Override protected VideoDecoderInputBuffer createInputBuffer() { return new VideoDecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); @@ -183,6 +174,15 @@ import java.nio.ByteBuffer; vpxClose(vpxDecContext); } + /** + * Sets the output mode for frames rendered by the decoder. + * + * @param outputMode The output mode. + */ + public void setOutputMode(@C.VideoOutputMode int outputMode) { + this.outputMode = outputMode; + } + /** Renders the outputBuffer to the surface. Used with OUTPUT_MODE_SURFACE_YUV only. */ public void renderToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface) throws VpxDecoderException { diff --git a/extensions/vp9/src/main/jni/vpx_jni.cc b/extensions/vp9/src/main/jni/vpx_jni.cc index 9996848047..1fc0f9d56e 100644 --- a/extensions/vp9/src/main/jni/vpx_jni.cc +++ b/extensions/vp9/src/main/jni/vpx_jni.cc @@ -65,9 +65,11 @@ static jfieldID dataField; static jfieldID outputModeField; static jfieldID decoderPrivateField; -// android.graphics.ImageFormat.YV12. -static const int kHalPixelFormatYV12 = 0x32315659; +// Android YUV format. See: +// https://developer.android.com/reference/android/graphics/ImageFormat.html#YV12. +static const int kImageFormatYV12 = 0x32315659; static const int kDecoderPrivateBase = 0x100; + static int errorCode; jint JNI_OnLoad(JavaVM* vm, void* reserved) { @@ -635,7 +637,7 @@ DECODER_FUNC(jint, vpxRenderFrame, jlong jContext, jobject jSurface, } if (context->width != srcBuffer->d_w || context->height != srcBuffer->d_h) { ANativeWindow_setBuffersGeometry(context->native_window, srcBuffer->d_w, - srcBuffer->d_h, kHalPixelFormatYV12); + srcBuffer->d_h, kImageFormatYV12); context->width = srcBuffer->d_w; context->height = srcBuffer->d_h; } From 0ff917ad3520dd082463edc797780d96f807b01c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 22 Jun 2020 09:42:35 +0100 Subject: [PATCH 0522/1052] Fix handling of postrolls preloading The IMA SDK now preloads postrolls which is great as we no longer need to rely on detecting buffering at the end of the stream to trigger playing postrolls. Add in the required logic to detect the period transition to playing the postroll. Issue: #7518 PiperOrigin-RevId: 317610682 --- RELEASENOTES.md | 2 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index aaf25b1cd5..76aee2f7f3 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -229,6 +229,8 @@ on seeking to another position ([#7492](https://github.com/google/ExoPlayer/issues/7492)). * Fix incorrect rounding of ad cue points. + * Fix handling of postrolls preloading + ([#7518](https://github.com/google/ExoPlayer/issues/7518)). ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index c055fb60d2..9cfd98fa0f 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1098,11 +1098,19 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { } if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { int adGroupIndex = player.getCurrentAdGroupIndex(); - // IMA hasn't called playAd yet, so fake the content position. - fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); - fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); - if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { - fakeContentProgressOffsetMs = contentDurationMs; + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { + adsLoader.contentComplete(); + if (DEBUG) { + Log.d(TAG, "adsLoader.contentComplete from period transition"); + } + sentContentComplete = true; + } else { + // IMA hasn't called playAd yet, so fake the content position. + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { + fakeContentProgressOffsetMs = contentDurationMs; + } } } } @@ -1221,7 +1229,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete"); + Log.d(TAG, "adsLoader.contentComplete from content position check"); } sentContentComplete = true; } From 8cccbcf4fd2f3d6d26ab6455b061b3b329a894af Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 22 Jun 2020 13:41:13 +0100 Subject: [PATCH 0523/1052] Migrate DefaultHttpDataSourceTest from Mockito to MockWebServer PiperOrigin-RevId: 317636681 --- constants.gradle | 1 + library/core/build.gradle | 1 + .../upstream/DefaultHttpDataSourceTest.java | 173 +++++++----------- 3 files changed, 65 insertions(+), 110 deletions(-) diff --git a/constants.gradle b/constants.gradle index 9f753ec3cd..e4f9a7f61d 100644 --- a/constants.gradle +++ b/constants.gradle @@ -23,6 +23,7 @@ project.ext { junitVersion = '4.13-rc-2' guavaVersion = '27.1-android' mockitoVersion = '2.25.0' + mockWebServerVersion = '3.12.0' robolectricVersion = '4.4-SNAPSHOT' checkerframeworkVersion = '2.5.0' jsr305Version = '3.0.2' diff --git a/library/core/build.gradle b/library/core/build.gradle index 70a2a92b2c..6ce6dac707 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -66,6 +66,7 @@ dependencies { exclude module: modulePrefix.substring(1) + 'library-core' } testImplementation 'com.google.guava:guava:' + guavaVersion + testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation project(modulePrefix + 'testutils') } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java index ce11bf3172..8d5a7479e5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java @@ -17,23 +17,19 @@ package com.google.android.exoplayer2.upstream; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.Assert.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.HttpURLConnection; import java.util.HashMap; import java.util.Map; +import okhttp3.Headers; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; /** Unit tests for {@link DefaultHttpDataSource}. */ @RunWith(AndroidJUnit4.class) @@ -45,86 +41,84 @@ public class DefaultHttpDataSourceTest { * table below. Values wrapped in '*' are the ones that should be set in the connection request. * *

          {@code
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          -   * |                       |            Header Key           |
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          -   * |       Location        | 0 |  1  |  2  |  3  |  4  |  5  |
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          -   * | Default               |*Y*|  Y  |  Y  |     |     |     |
          -   * | DefaultHttpDataSource |   | *Y* |  Y  |  Y  | *Y* |     |
          -   * | DataSpec              |   |     | *Y* | *Y* |     | *Y* |
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
          +   * |               |               Header Key                |
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
          +   * |   Location    |  0  |  1  |  2  |  3  |  4  |  5  |  6  |
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
          +   * | Constructor   | *Y* |  Y  |  Y  |     |  Y  |     |     |
          +   * | Setter        |     | *Y* |  Y  |  Y  |     | *Y* |     |
          +   * | DataSpec      |     |     | *Y* | *Y* | *Y* |     | *Y* |
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
              * }
          */ @Test - public void open_withSpecifiedRequestParameters_usesCorrectParameters() throws IOException { - String defaultParameter = "Default"; - String dataSourceInstanceParameter = "DefaultHttpDataSource"; - String dataSpecParameter = "Dataspec"; + public void open_withSpecifiedRequestParameters_usesCorrectParameters() throws Exception { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse()); - HttpDataSource.RequestProperties defaultParameters = new HttpDataSource.RequestProperties(); - defaultParameters.set("0", defaultParameter); - defaultParameters.set("1", defaultParameter); - defaultParameters.set("2", defaultParameter); + String propertyFromConstructor = "fromConstructor"; + HttpDataSource.RequestProperties constructorProperties = new HttpDataSource.RequestProperties(); + constructorProperties.set("0", propertyFromConstructor); + constructorProperties.set("1", propertyFromConstructor); + constructorProperties.set("2", propertyFromConstructor); + constructorProperties.set("4", propertyFromConstructor); + DefaultHttpDataSource dataSource = + new DefaultHttpDataSource( + /* userAgent= */ "testAgent", + /* connectTimeoutMillis= */ 1000, + /* readTimeoutMillis= */ 1000, + /* allowCrossProtocolRedirects= */ false, + constructorProperties); - DefaultHttpDataSource defaultHttpDataSource = - Mockito.spy( - new DefaultHttpDataSource( - /* userAgent= */ "testAgent", - /* connectTimeoutMillis= */ 1000, - /* readTimeoutMillis= */ 1000, - /* allowCrossProtocolRedirects= */ false, - defaultParameters)); - - Map sentRequestProperties = new HashMap<>(); - HttpURLConnection mockHttpUrlConnection = make200MockHttpUrlConnection(sentRequestProperties); - doReturn(mockHttpUrlConnection).when(defaultHttpDataSource).openConnection(any()); - - defaultHttpDataSource.setRequestProperty("1", dataSourceInstanceParameter); - defaultHttpDataSource.setRequestProperty("2", dataSourceInstanceParameter); - defaultHttpDataSource.setRequestProperty("3", dataSourceInstanceParameter); - defaultHttpDataSource.setRequestProperty("4", dataSourceInstanceParameter); + String propertyFromSetter = "fromSetter"; + dataSource.setRequestProperty("1", propertyFromSetter); + dataSource.setRequestProperty("2", propertyFromSetter); + dataSource.setRequestProperty("3", propertyFromSetter); + dataSource.setRequestProperty("5", propertyFromSetter); + String propertyFromDataSpec = "fromDataSpec"; Map dataSpecRequestProperties = new HashMap<>(); - dataSpecRequestProperties.put("2", dataSpecParameter); - dataSpecRequestProperties.put("3", dataSpecParameter); - dataSpecRequestProperties.put("5", dataSpecParameter); - + dataSpecRequestProperties.put("2", propertyFromDataSpec); + dataSpecRequestProperties.put("3", propertyFromDataSpec); + dataSpecRequestProperties.put("4", propertyFromDataSpec); + dataSpecRequestProperties.put("6", propertyFromDataSpec); DataSpec dataSpec = new DataSpec.Builder() - .setUri("http://www.google.com") - .setHttpBody(new byte[] {0, 0, 0, 0}) - .setLength(1) - .setKey("key") + .setUri(mockWebServer.url("/test-path").toString()) .setHttpRequestHeaders(dataSpecRequestProperties) .build(); - defaultHttpDataSource.open(dataSpec); + dataSource.open(dataSpec); - assertThat(sentRequestProperties.get("0")).isEqualTo(defaultParameter); - assertThat(sentRequestProperties.get("1")).isEqualTo(dataSourceInstanceParameter); - assertThat(sentRequestProperties.get("2")).isEqualTo(dataSpecParameter); - assertThat(sentRequestProperties.get("3")).isEqualTo(dataSpecParameter); - assertThat(sentRequestProperties.get("4")).isEqualTo(dataSourceInstanceParameter); - assertThat(sentRequestProperties.get("5")).isEqualTo(dataSpecParameter); + Headers headers = mockWebServer.takeRequest(10, SECONDS).getHeaders(); + assertThat(headers.get("0")).isEqualTo(propertyFromConstructor); + assertThat(headers.get("1")).isEqualTo(propertyFromSetter); + assertThat(headers.get("2")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("3")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("4")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("5")).isEqualTo(propertyFromSetter); + assertThat(headers.get("6")).isEqualTo(propertyFromDataSpec); } @Test public void open_invalidResponseCode() throws Exception { DefaultHttpDataSource defaultHttpDataSource = - Mockito.spy( - new DefaultHttpDataSource( - /* userAgent= */ "testAgent", - /* connectTimeoutMillis= */ 1000, - /* readTimeoutMillis= */ 1000, - /* allowCrossProtocolRedirects= */ false, - /* defaultRequestProperties= */ null)); + new DefaultHttpDataSource( + /* userAgent= */ "testAgent", + /* connectTimeoutMillis= */ 1000, + /* readTimeoutMillis= */ 1000, + /* allowCrossProtocolRedirects= */ false, + /* defaultRequestProperties= */ null); - HttpURLConnection mockHttpUrlConnection = - make404MockHttpUrlConnection(/* responseData= */ TestUtil.createByteArray(1, 2, 3)); - doReturn(mockHttpUrlConnection).when(defaultHttpDataSource).openConnection(any()); + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(404) + .setBody(new Buffer().write(TestUtil.createByteArray(1, 2, 3)))); - DataSpec dataSpec = new DataSpec.Builder().setUri("http://www.google.com").build(); + DataSpec dataSpec = + new DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build(); HttpDataSource.InvalidResponseCodeException exception = assertThrows( @@ -134,45 +128,4 @@ public class DefaultHttpDataSourceTest { assertThat(exception.responseCode).isEqualTo(404); assertThat(exception.responseBody).isEqualTo(TestUtil.createByteArray(1, 2, 3)); } - - /** - * Creates a mock {@link HttpURLConnection} that stores all request parameters inside {@code - * requestProperties}. - */ - private static HttpURLConnection make200MockHttpUrlConnection( - Map requestProperties) throws IOException { - HttpURLConnection mockHttpUrlConnection = Mockito.mock(HttpURLConnection.class); - when(mockHttpUrlConnection.usingProxy()).thenReturn(false); - - when(mockHttpUrlConnection.getInputStream()) - .thenReturn(new ByteArrayInputStream(new byte[128])); - - when(mockHttpUrlConnection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); - - when(mockHttpUrlConnection.getResponseCode()).thenReturn(200); - when(mockHttpUrlConnection.getResponseMessage()).thenReturn("OK"); - - Mockito.doAnswer( - (invocation) -> { - String key = invocation.getArgument(0); - String value = invocation.getArgument(1); - requestProperties.put(key, value); - return null; - }) - .when(mockHttpUrlConnection) - .setRequestProperty(ArgumentMatchers.anyString(), ArgumentMatchers.anyString()); - - return mockHttpUrlConnection; - } - - /** Creates a mock {@link HttpURLConnection} that returns a 404 response code. */ - private static HttpURLConnection make404MockHttpUrlConnection(byte[] responseData) - throws IOException { - HttpURLConnection mockHttpUrlConnection = Mockito.mock(HttpURLConnection.class); - when(mockHttpUrlConnection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); - when(mockHttpUrlConnection.getErrorStream()).thenReturn(new ByteArrayInputStream(responseData)); - when(mockHttpUrlConnection.getResponseCode()).thenReturn(404); - when(mockHttpUrlConnection.getResponseMessage()).thenReturn("NOT FOUND"); - return mockHttpUrlConnection; - } } From a6f79901e75cc503d5d534e32d2cbe08ec1e977e Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 22 Jun 2020 13:45:01 +0100 Subject: [PATCH 0524/1052] Migrate OkHttpDataSourceTest from Mockito to MockWebServer PiperOrigin-RevId: 317637058 --- extensions/okhttp/build.gradle | 1 + .../ext/okhttp/OkHttpDataSourceTest.java | 149 ++++++------------ 2 files changed, 53 insertions(+), 97 deletions(-) diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 220522b9d9..a44e62e0e5 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -37,6 +37,7 @@ dependencies { compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') + testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion // Do not update to 3.13.X or later until minSdkVersion is increased to 21: // https://cashapp.github.io/2019-02-05/okhttp-3-13-requires-android-5 diff --git a/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java b/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java index d39e836869..73e9909a8d 100644 --- a/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java +++ b/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java @@ -17,28 +17,21 @@ package com.google.android.exoplayer2.ext.okhttp; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.HttpDataSource; -import java.io.IOException; +import com.google.common.base.Charsets; import java.util.HashMap; import java.util.Map; -import okhttp3.Call; -import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; +import okhttp3.Headers; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; /** Unit tests for {@link OkHttpDataSource}. */ @RunWith(AndroidJUnit4.class) @@ -50,114 +43,76 @@ public class OkHttpDataSourceTest { * below. Values wrapped in '*' are the ones that should be set in the connection request. * *
          {@code
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          -   * |                       |            Header Key           |
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          -   * |       Location        | 0 |  1  |  2  |  3  |  4  |  5  |
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          -   * | Default               |*Y*|  Y  |  Y  |     |     |     |
          -   * | OkHttpDataSource      |   | *Y* |  Y  |  Y  | *Y* |     |
          -   * | DataSpec              |   |     | *Y* | *Y* |     | *Y* |
          -   * +-----------------------+---+-----+-----+-----+-----+-----+
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
          +   * |               |               Header Key                |
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
          +   * |   Location    |  0  |  1  |  2  |  3  |  4  |  5  |  6  |
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
          +   * | Constructor   | *Y* |  Y  |  Y  |     |  Y  |     |     |
          +   * | Setter        |     | *Y* |  Y  |  Y  |     | *Y* |     |
          +   * | DataSpec      |     |     | *Y* | *Y* | *Y* |     | *Y* |
          +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
              * }
          */ @Test - public void open_setsCorrectHeaders() throws HttpDataSource.HttpDataSourceException { - String defaultValue = "Default"; - String okHttpDataSourceValue = "OkHttpDataSource"; - String dataSpecValue = "DataSpec"; + public void open_setsCorrectHeaders() throws Exception { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse()); - // 1. Default properties on OkHttpDataSource - HttpDataSource.RequestProperties defaultRequestProperties = - new HttpDataSource.RequestProperties(); - defaultRequestProperties.set("0", defaultValue); - defaultRequestProperties.set("1", defaultValue); - defaultRequestProperties.set("2", defaultValue); - - Call.Factory mockCallFactory = mock(Call.Factory.class); - OkHttpDataSource okHttpDataSource = + String propertyFromConstructor = "fromConstructor"; + HttpDataSource.RequestProperties constructorProperties = new HttpDataSource.RequestProperties(); + constructorProperties.set("0", propertyFromConstructor); + constructorProperties.set("1", propertyFromConstructor); + constructorProperties.set("2", propertyFromConstructor); + constructorProperties.set("4", propertyFromConstructor); + OkHttpDataSource dataSource = new OkHttpDataSource( - mockCallFactory, "testAgent", /* cacheControl= */ null, defaultRequestProperties); + new OkHttpClient(), "testAgent", /* cacheControl= */ null, constructorProperties); - // 2. Additional properties set with setRequestProperty(). - okHttpDataSource.setRequestProperty("1", okHttpDataSourceValue); - okHttpDataSource.setRequestProperty("2", okHttpDataSourceValue); - okHttpDataSource.setRequestProperty("3", okHttpDataSourceValue); - okHttpDataSource.setRequestProperty("4", okHttpDataSourceValue); + String propertyFromSetter = "fromSetter"; + dataSource.setRequestProperty("1", propertyFromSetter); + dataSource.setRequestProperty("2", propertyFromSetter); + dataSource.setRequestProperty("3", propertyFromSetter); + dataSource.setRequestProperty("5", propertyFromSetter); - // 3. DataSpec properties + String propertyFromDataSpec = "fromDataSpec"; Map dataSpecRequestProperties = new HashMap<>(); - dataSpecRequestProperties.put("2", dataSpecValue); - dataSpecRequestProperties.put("3", dataSpecValue); - dataSpecRequestProperties.put("5", dataSpecValue); + dataSpecRequestProperties.put("2", propertyFromDataSpec); + dataSpecRequestProperties.put("3", propertyFromDataSpec); + dataSpecRequestProperties.put("4", propertyFromDataSpec); + dataSpecRequestProperties.put("6", propertyFromDataSpec); DataSpec dataSpec = new DataSpec.Builder() - .setUri("http://www.google.com") - .setPosition(1000) - .setLength(5000) + .setUri(mockWebServer.url("/test-path").toString()) .setHttpRequestHeaders(dataSpecRequestProperties) .build(); - Mockito.doAnswer( - invocation -> { - Request request = invocation.getArgument(0); - assertThat(request.header("0")).isEqualTo(defaultValue); - assertThat(request.header("1")).isEqualTo(okHttpDataSourceValue); - assertThat(request.header("2")).isEqualTo(dataSpecValue); - assertThat(request.header("3")).isEqualTo(dataSpecValue); - assertThat(request.header("4")).isEqualTo(okHttpDataSourceValue); - assertThat(request.header("5")).isEqualTo(dataSpecValue); + dataSource.open(dataSpec); - // return a Call whose .execute() will return a mock Response - Call returnValue = mock(Call.class); - doReturn( - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(ResponseBody.create(MediaType.parse("text/plain"), "")) - .build()) - .when(returnValue) - .execute(); - return returnValue; - }) - .when(mockCallFactory) - .newCall(ArgumentMatchers.any()); - okHttpDataSource.open(dataSpec); + Headers headers = mockWebServer.takeRequest(10, SECONDS).getHeaders(); + assertThat(headers.get("0")).isEqualTo(propertyFromConstructor); + assertThat(headers.get("1")).isEqualTo(propertyFromSetter); + assertThat(headers.get("2")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("3")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("4")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("5")).isEqualTo(propertyFromSetter); + assertThat(headers.get("6")).isEqualTo(propertyFromDataSpec); } @Test public void open_invalidResponseCode() throws Exception { - Call.Factory callFactory = - request -> { - Call mockCall = mock(Call.class); - - try { - when(mockCall.execute()) - .thenReturn( - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(404) - .message("NOT FOUND") - .body(ResponseBody.create(MediaType.parse("text/plain"), "failure msg")) - .build()); - } catch (IOException e) { - throw new AssertionError(e); - } - return mockCall; - }; + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("failure msg")); OkHttpDataSource okHttpDataSource = new OkHttpDataSource( - callFactory, + new OkHttpClient(), "testAgent", /* cacheControl= */ null, /* defaultRequestProperties= */ null); - - DataSpec dataSpec = new DataSpec.Builder().setUri("http://www.google.com").build(); + DataSpec dataSpec = + new DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build(); HttpDataSource.InvalidResponseCodeException exception = assertThrows( @@ -165,6 +120,6 @@ public class OkHttpDataSourceTest { () -> okHttpDataSource.open(dataSpec)); assertThat(exception.responseCode).isEqualTo(404); - assertThat(exception.responseBody).isEqualTo("failure msg".getBytes(C.UTF8_NAME)); + assertThat(exception.responseBody).isEqualTo("failure msg".getBytes(Charsets.UTF_8)); } } From 1836f1df36ba3c51a7be987fe754fbf0d7fb5a98 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 22 Jun 2020 16:26:13 +0100 Subject: [PATCH 0525/1052] Update Checkerframework. The compat dependency is no longer maintained and we need to keep it at its old version. PiperOrigin-RevId: 317658349 --- constants.gradle | 3 ++- demos/gl/build.gradle | 2 +- extensions/cast/build.gradle | 2 +- library/common/build.gradle | 2 +- library/core/build.gradle | 2 +- library/dash/build.gradle | 2 +- library/extractor/build.gradle | 2 +- library/hls/build.gradle | 2 +- library/smoothstreaming/build.gradle | 2 +- testutils/build.gradle | 2 +- 10 files changed, 11 insertions(+), 10 deletions(-) diff --git a/constants.gradle b/constants.gradle index e4f9a7f61d..22766d40a0 100644 --- a/constants.gradle +++ b/constants.gradle @@ -25,7 +25,8 @@ project.ext { mockitoVersion = '2.25.0' mockWebServerVersion = '3.12.0' robolectricVersion = '4.4-SNAPSHOT' - checkerframeworkVersion = '2.5.0' + checkerframeworkVersion = '3.3.0' + checkerframeworkCompatVersion = '2.5.0' jsr305Version = '3.0.2' kotlinAnnotationsVersion = '1.3.70' androidxAnnotationVersion = '1.1.0' diff --git a/demos/gl/build.gradle b/demos/gl/build.gradle index 8fe3e04045..e065f9b8f2 100644 --- a/demos/gl/build.gradle +++ b/demos/gl/build.gradle @@ -49,5 +49,5 @@ dependencies { implementation project(modulePrefix + 'library-dash') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion } diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index 853861e4ad..58bb15dff2 100644 --- a/extensions/cast/build.gradle +++ b/extensions/cast/build.gradle @@ -36,7 +36,7 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-ui') compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion diff --git a/library/common/build.gradle b/library/common/build.gradle index 14bfd6aba5..27ca38d444 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -41,7 +41,7 @@ dependencies { implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion diff --git a/library/core/build.gradle b/library/core/build.gradle index 6ce6dac707..d95629e0d7 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -56,7 +56,7 @@ dependencies { implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion androidTestImplementation 'com.google.guava:guava:' + guavaVersion diff --git a/library/dash/build.gradle b/library/dash/build.gradle index 33cbab1b90..26515c8470 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -42,7 +42,7 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index d9a5128b13..52cc193550 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -43,7 +43,7 @@ dependencies { implementation project(modulePrefix + 'library-common') implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') diff --git a/library/hls/build.gradle b/library/hls/build.gradle index 152fd35dff..e4630f6044 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -42,7 +42,7 @@ dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') diff --git a/library/smoothstreaming/build.gradle b/library/smoothstreaming/build.gradle index 404f1d6541..e9ffcd9b5b 100644 --- a/library/smoothstreaming/build.gradle +++ b/library/smoothstreaming/build.gradle @@ -41,7 +41,7 @@ android { dependencies { implementation project(modulePrefix + 'library-core') compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion testImplementation project(modulePrefix + 'testutils') diff --git a/testutils/build.gradle b/testutils/build.gradle index e4cfd7cab0..931203b3bb 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -37,7 +37,7 @@ dependencies { api 'junit:junit:' + junitVersion api 'com.google.truth:truth:' + truthVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'library-core') From 836babd5d6158c8e8fe058acd836d4bdff7f5eef Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 22 Jun 2020 17:44:44 +0100 Subject: [PATCH 0526/1052] Replace usages of Charset.forName with Guava Charsets. PiperOrigin-RevId: 317672619 --- .../java/com/google/android/exoplayer2/C.java | 31 +++++++++++++------ .../exoplayer2/util/ParsableBitArray.java | 4 +-- .../exoplayer2/util/ParsableByteArray.java | 4 +-- .../google/android/exoplayer2/util/Util.java | 8 ++--- .../metadata/id3/Id3DecoderTest.java | 5 ++- .../exoplayer2/util/ParsableBitArrayTest.java | 11 +++---- .../exoplayer2/drm/FrameworkMediaDrm.java | 6 ++-- .../metadata/dvbsi/AppInfoTableDecoder.java | 8 ++--- .../exoplayer2/metadata/icy/IcyDecoder.java | 7 ++--- .../exoplayer2/text/tx3g/Tx3gDecoder.java | 6 ++-- .../upstream/DataSchemeDataSource.java | 3 +- .../cache/DefaultContentMetadata.java | 7 ++--- .../source/dash/DashMediaSource.java | 5 ++- .../dash/manifest/DashManifestParser.java | 3 +- .../dash/manifest/DashManifestParserTest.java | 8 ++--- .../dash/offline/DashDownloadTestData.java | 7 ++--- .../extractor/FlacMetadataReader.java | 5 ++- .../hls/offline/HlsDownloadTestData.java | 9 +++--- .../playlist/HlsMasterPlaylistParserTest.java | 4 +-- library/ui/build.gradle | 1 + .../exoplayer2/ui/WebViewSubtitleOutput.java | 6 ++-- 21 files changed, 73 insertions(+), 75 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index 7558844b87..64123b730e 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -85,23 +85,34 @@ public final class C { public static final int BYTES_PER_FLOAT = 4; /** - * The name of the ASCII charset. + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. */ - public static final String ASCII_NAME = "US-ASCII"; + @Deprecated public static final String ASCII_NAME = "US-ASCII"; /** - * The name of the UTF-8 charset. + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. */ - public static final String UTF8_NAME = "UTF-8"; + @Deprecated public static final String UTF8_NAME = "UTF-8"; - /** The name of the ISO-8859-1 charset. */ - public static final String ISO88591_NAME = "ISO-8859-1"; + /** + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. + */ + @Deprecated public static final String ISO88591_NAME = "ISO-8859-1"; - /** The name of the UTF-16 charset. */ - public static final String UTF16_NAME = "UTF-16"; + /** + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. + */ + @Deprecated public static final String UTF16_NAME = "UTF-16"; - /** The name of the UTF-16 little-endian charset. */ - public static final String UTF16LE_NAME = "UTF-16LE"; + /** + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. + */ + @Deprecated public static final String UTF16LE_NAME = "UTF-16LE"; /** * The name of the serif font family. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java index 963e43fc7e..4f6f583528 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.util; -import com.google.android.exoplayer2.C; +import com.google.common.base.Charsets; import java.nio.charset.Charset; /** @@ -288,7 +288,7 @@ public final class ParsableBitArray { * @return The string encoded by the bytes in UTF-8. */ public String readBytesAsString(int length) { - return readBytesAsString(length, Charset.forName(C.UTF8_NAME)); + return readBytesAsString(length, Charsets.UTF_8); } /** diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index 2acab348ad..e62183f944 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.util; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; import java.nio.charset.Charset; @@ -449,7 +449,7 @@ public final class ParsableByteArray { * @return The string encoded by the bytes. */ public String readString(int length) { - return readString(length, Charset.forName(C.UTF8_NAME)); + return readString(length, Charsets.UTF_8); } /** diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 838de61db4..3c76c7bbeb 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -54,6 +54,7 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.upstream.DataSource; import com.google.common.base.Ascii; +import com.google.common.base.Charsets; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.File; @@ -63,7 +64,6 @@ import java.lang.reflect.Method; import java.math.BigDecimal; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; @@ -595,7 +595,7 @@ public final class Util { * @return The string. */ public static String fromUtf8Bytes(byte[] bytes) { - return new String(bytes, Charset.forName(C.UTF8_NAME)); + return new String(bytes, Charsets.UTF_8); } /** @@ -607,7 +607,7 @@ public final class Util { * @return The string. */ public static String fromUtf8Bytes(byte[] bytes, int offset, int length) { - return new String(bytes, offset, length, Charset.forName(C.UTF8_NAME)); + return new String(bytes, offset, length, Charsets.UTF_8); } /** @@ -617,7 +617,7 @@ public final class Util { * @return The code points encoding using UTF-8. */ public static byte[] getUtf8Bytes(String value) { - return value.getBytes(Charset.forName(C.UTF8_NAME)); + return value.getBytes(Charsets.UTF_8); } /** diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java index 6389417464..52bf0135fe 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java @@ -21,11 +21,10 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; import com.google.android.exoplayer2.util.Assertions; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; @@ -287,7 +286,7 @@ public final class Id3DecoderTest { for (FrameSpec frame : frames) { byte[] frameData = frame.frameData; String frameId = frame.frameId; - byte[] frameIdBytes = frameId.getBytes(Charset.forName(C.UTF8_NAME)); + byte[] frameIdBytes = frameId.getBytes(Charsets.UTF_8); Assertions.checkState(frameIdBytes.length == 4); // Fill in the frame header. diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java index 9a2d17cbfc..5d75ae9f09 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java @@ -19,9 +19,8 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.TestUtil; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import org.junit.Test; import org.junit.runner.RunWith; @@ -281,7 +280,7 @@ public final class ParsableBitArrayTest { @Test public void readBytesAsStringDefaultsToUtf8() { - byte[] testData = "a non-åscii strìng".getBytes(Charset.forName(C.UTF8_NAME)); + byte[] testData = "a non-åscii strìng".getBytes(Charsets.UTF_8); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBytes(2); @@ -290,18 +289,18 @@ public final class ParsableBitArrayTest { @Test public void readBytesAsStringExplicitCharset() { - byte[] testData = "a non-åscii strìng".getBytes(Charset.forName(C.UTF16_NAME)); + byte[] testData = "a non-åscii strìng".getBytes(Charsets.UTF_16); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBytes(6); - assertThat(testArray.readBytesAsString(testData.length - 6, Charset.forName(C.UTF16_NAME))) + assertThat(testArray.readBytesAsString(testData.length - 6, Charsets.UTF_16)) .isEqualTo("non-åscii strìng"); } @Test public void readBytesNotByteAligned() { String testString = "test string"; - byte[] testData = testString.getBytes(Charset.forName(C.UTF8_NAME)); + byte[] testData = testString.getBytes(Charsets.UTF_8); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBit(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index 74881646a2..ca4c175b15 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -34,9 +34,9 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -438,7 +438,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { return data; } int recordLength = byteArray.readLittleEndianShort(); - String xml = byteArray.readString(recordLength, Charset.forName(C.UTF16LE_NAME)); + String xml = byteArray.readString(recordLength, Charsets.UTF_16LE); if (xml.contains("")) { // LA_URL already present. Do nothing. return data; @@ -459,7 +459,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { newData.putShort((short) objectRecordCount); newData.putShort((short) recordType); newData.putShort((short) (xmlWithMockLaUrl.length() * UTF_16_BYTES_PER_CHARACTER)); - newData.put(xmlWithMockLaUrl.getBytes(Charset.forName(C.UTF16LE_NAME))); + newData.put(xmlWithMockLaUrl.getBytes(Charsets.UTF_16LE)); return newData.array(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java index f533b97d13..15f633f67f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java @@ -16,14 +16,13 @@ package com.google.android.exoplayer2.metadata.dvbsi; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; -import java.nio.charset.Charset; import java.util.ArrayList; /** @@ -109,7 +108,7 @@ public final class AppInfoTableDecoder implements MetadataDecoder { // See section 5.3.6.2. while (sectionData.getBytePosition() < positionOfNextDescriptor) { int urlBaseLength = sectionData.readBits(8); - urlBase = sectionData.readBytesAsString(urlBaseLength, Charset.forName(C.ASCII_NAME)); + urlBase = sectionData.readBytesAsString(urlBaseLength, Charsets.US_ASCII); int extensionCount = sectionData.readBits(8); for (int urlExtensionIndex = 0; @@ -122,8 +121,7 @@ public final class AppInfoTableDecoder implements MetadataDecoder { } } else if (descriptorTag == DESCRIPTOR_SIMPLE_APPLICATION_LOCATION) { // See section 5.3.7. - urlExtension = - sectionData.readBytesAsString(descriptorLength, Charset.forName(C.ASCII_NAME)); + urlExtension = sectionData.readBytesAsString(descriptorLength, Charsets.US_ASCII); } sectionData.setPosition(positionOfNextDescriptor * 8); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java index cd3c1dfb63..aa5e83a682 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java @@ -16,15 +16,14 @@ package com.google.android.exoplayer2.metadata.icy; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; -import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -40,8 +39,8 @@ public final class IcyDecoder implements MetadataDecoder { private final CharsetDecoder iso88591Decoder; public IcyDecoder() { - utf8Decoder = Charset.forName(C.UTF8_NAME).newDecoder(); - iso88591Decoder = Charset.forName(C.ISO88591_NAME).newDecoder(); + utf8Decoder = Charsets.UTF_8.newDecoder(); + iso88591Decoder = Charsets.ISO_8859_1.newDecoder(); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java index c8f2979c58..c290ec4c86 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java @@ -30,7 +30,7 @@ import com.google.android.exoplayer2.text.Subtitle; import com.google.android.exoplayer2.text.SubtitleDecoderException; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import java.util.List; /** @@ -171,10 +171,10 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { if (parsableByteArray.bytesLeft() >= SIZE_BOM_UTF16) { char firstChar = parsableByteArray.peekChar(); if (firstChar == BOM_UTF16_BE || firstChar == BOM_UTF16_LE) { - return parsableByteArray.readString(textLength, Charset.forName(C.UTF16_NAME)); + return parsableByteArray.readString(textLength, Charsets.UTF_16); } } - return parsableByteArray.readString(textLength, Charset.forName(C.UTF8_NAME)); + return parsableByteArray.readString(textLength, Charsets.UTF_8); } private void applyStyleRecord(ParsableByteArray parsableByteArray, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java index 55c580ead2..86e6bcfe75 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java @@ -23,6 +23,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.io.IOException; import java.net.URLDecoder; @@ -63,7 +64,7 @@ public final class DataSchemeDataSource extends BaseDataSource { } } else { // TODO: Add support for other charsets. - data = Util.getUtf8Bytes(URLDecoder.decode(dataString, C.ASCII_NAME)); + data = Util.getUtf8Bytes(URLDecoder.decode(dataString, Charsets.US_ASCII.name())); } endPosition = dataSpec.length != C.LENGTH_UNSET ? (int) dataSpec.length + readPosition : data.length; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java index c3f06252e4..706fa0d2c3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java @@ -16,9 +16,8 @@ package com.google.android.exoplayer2.upstream.cache; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -80,7 +79,7 @@ public final class DefaultContentMetadata implements ContentMetadata { public final String get(String name, @Nullable String defaultValue) { @Nullable byte[] bytes = metadata.get(name); if (bytes != null) { - return new String(bytes, Charset.forName(C.UTF8_NAME)); + return new String(bytes, Charsets.UTF_8); } else { return defaultValue; } @@ -162,7 +161,7 @@ public final class DefaultContentMetadata implements ContentMetadata { if (value instanceof Long) { return ByteBuffer.allocate(8).putLong((Long) value).array(); } else if (value instanceof String) { - return ((String) value).getBytes(Charset.forName(C.UTF8_NAME)); + return ((String) value).getBytes(Charsets.UTF_8); } else if (value instanceof byte[]) { return (byte[]) value; } else { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index fe2b18814b..6baeadd52e 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -63,11 +63,11 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.SntpClient; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.nio.charset.Charset; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Collections; @@ -1451,8 +1451,7 @@ public final class DashMediaSource extends BaseMediaSource { @Override public Long parse(Uri uri, InputStream inputStream) throws IOException { String firstLine = - new BufferedReader(new InputStreamReader(inputStream, Charset.forName(C.UTF8_NAME))) - .readLine(); + new BufferedReader(new InputStreamReader(inputStream, Charsets.UTF_8)).readLine(); try { Matcher matcher = TIMESTAMP_WITH_TIMEZONE_PATTERN.matcher(firstLine); if (!matcher.matches()) { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 3a51d34e20..e9e9c66df2 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -39,6 +39,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.XmlPullParserUtil; +import com.google.common.base.Charsets; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -933,7 +934,7 @@ public class DashManifestParser extends DefaultHandler throws XmlPullParserException, IOException { scratchOutputStream.reset(); XmlSerializer xmlSerializer = Xml.newSerializer(); - xmlSerializer.setOutput(scratchOutputStream, C.UTF8_NAME); + xmlSerializer.setOutput(scratchOutputStream, Charsets.UTF_8.name()); // Start reading everything between and , and serialize them into an Xml // byte array. xpp.nextToken(); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java index 47087472ae..19ad13ffde 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -28,9 +28,9 @@ import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SegmentTim import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.io.IOException; import java.io.StringReader; -import java.nio.charset.Charset; import java.util.Collections; import java.util.List; import org.junit.Test; @@ -116,11 +116,7 @@ public class DashManifestParserTest { assertThat(eventStream1.events.length).isEqualTo(1); EventMessage expectedEvent1 = new EventMessage( - "urn:uuid:XYZY", - "call", - 10000, - 0, - "+ 1 800 10101010".getBytes(Charset.forName(C.UTF8_NAME))); + "urn:uuid:XYZY", "call", 10000, 0, "+ 1 800 10101010".getBytes(Charsets.UTF_8)); assertThat(eventStream1.events[0]).isEqualTo(expectedEvent1); assertThat(eventStream1.presentationTimesUs[0]).isEqualTo(0); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java index 71f7c9a187..95b460a4cf 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java @@ -16,8 +16,7 @@ package com.google.android.exoplayer2.source.dash.offline; import android.net.Uri; -import com.google.android.exoplayer2.C; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; /** Data for DASH downloading tests. */ /* package */ interface DashDownloadTestData { @@ -87,7 +86,7 @@ import java.nio.charset.Charset; + " \n" + " \n" + "") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); byte[] TEST_MPD_NO_INDEX = ("\n" @@ -100,5 +99,5 @@ import java.nio.charset.Charset; + " \n" + " \n" + "") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java index 65e65c401e..5a89d63edc 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.extractor; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.VorbisUtil.CommentHeader; import com.google.android.exoplayer2.metadata.Metadata; @@ -25,8 +24,8 @@ import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.FlacConstants; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.base.Charsets; import java.io.IOException; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -275,7 +274,7 @@ public final class FlacMetadataReader { int pictureType = scratch.readInt(); int mimeTypeLength = scratch.readInt(); - String mimeType = scratch.readString(mimeTypeLength, Charset.forName(C.ASCII_NAME)); + String mimeType = scratch.readString(mimeTypeLength, Charsets.US_ASCII); int descriptionLength = scratch.readInt(); String description = scratch.readString(descriptionLength); int width = scratch.readInt(); diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java index f38a4577be..9215dd31f0 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java @@ -15,8 +15,7 @@ */ package com.google.android.exoplayer2.source.hls.offline; -import com.google.android.exoplayer2.C; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; /** Data for HLS downloading tests. */ /* package */ interface HlsDownloadTestData { @@ -49,7 +48,7 @@ import java.nio.charset.Charset; + "\n" + "#EXT-X-STREAM-INF:BANDWIDTH=41457,CODECS=\"mp4a.40.2\"\n" + MEDIA_PLAYLIST_0_URI) - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); byte[] MEDIA_PLAYLIST_DATA = ("#EXTM3U\n" @@ -64,7 +63,7 @@ import java.nio.charset.Charset; + "#EXTINF:9.97667,\n" + "fileSequence2.ts\n" + "#EXT-X-ENDLIST") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); String ENC_MEDIA_PLAYLIST_URI = "enc_index.m3u8"; @@ -83,5 +82,5 @@ import java.nio.charset.Charset; + "#EXTINF:9.97667,\n" + "fileSequence2.ts\n" + "#EXT-X-ENDLIST") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java index 05bc3ba985..145d01bbb5 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java @@ -27,9 +27,9 @@ import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.base.Charsets; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -461,7 +461,7 @@ public class HlsMasterPlaylistParserTest { throws IOException { Uri playlistUri = Uri.parse(uri); ByteArrayInputStream inputStream = - new ByteArrayInputStream(playlistString.getBytes(Charset.forName(C.UTF8_NAME))); + new ByteArrayInputStream(playlistString.getBytes(Charsets.UTF_8)); return (HlsMasterPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); } } diff --git a/library/ui/build.gradle b/library/ui/build.gradle index 5534b5bf48..e8c6327e25 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -41,6 +41,7 @@ dependencies { api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index 2bf3d2e6ab..ecab29ddff 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -29,11 +29,10 @@ import android.view.MotionEvent; import android.webkit.WebView; import android.widget.FrameLayout; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.CaptionStyleCompat; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.util.Util; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -288,8 +287,7 @@ import java.util.List; html.append(""); webView.loadData( - Base64.encodeToString( - html.toString().getBytes(Charset.forName(C.UTF8_NAME)), Base64.NO_PADDING), + Base64.encodeToString(html.toString().getBytes(Charsets.UTF_8), Base64.NO_PADDING), "text/html", "base64"); } From 093f9931b49a6a50a0c1e47c7fdc4600185ed7f8 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 23 Jun 2020 10:55:10 +0100 Subject: [PATCH 0527/1052] Fix resuming postrolls Postrolls would be skipped because the period duration wasn't know at the moment of resuming playback after backgrounding, so the position wouldn't be resolved to resume the postroll ad. We have the period duration stored in the AdPlaybackState, so we can use that directly. Issue: #7518 PiperOrigin-RevId: 317830418 --- .../exoplayer2/source/ads/AdPlaybackState.java | 4 +++- .../source/ads/SinglePeriodAdTimeline.java | 13 +++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 70128c78bf..3a093ca79f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -270,7 +270,9 @@ public final class AdPlaybackState { public final AdGroup[] adGroups; /** The position offset in the first unplayed ad at which to begin playback, in microseconds. */ public final long adResumePositionUs; - /** The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. */ + /** + * The duration of the content period in microseconds, if known. {@link C#TIME_UNSET} otherwise. + */ public final long contentDurationUs; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java index b5167dc173..cc82510a29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java @@ -44,23 +44,16 @@ public final class SinglePeriodAdTimeline extends ForwardingTimeline { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { timeline.getPeriod(periodIndex, period, setIds); + long durationUs = + period.durationUs == C.TIME_UNSET ? adPlaybackState.contentDurationUs : period.durationUs; period.set( period.id, period.uid, period.windowIndex, - period.durationUs, + durationUs, period.getPositionInWindowUs(), adPlaybackState); return period; } - @Override - public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { - window = super.getWindow(windowIndex, window, defaultPositionProjectionUs); - if (window.durationUs == C.TIME_UNSET) { - window.durationUs = adPlaybackState.contentDurationUs; - } - return window; - } - } From f39a65cb66a7cb79e2552513a146082d4f39a5cb Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 17 Jun 2020 21:07:38 +0100 Subject: [PATCH 0528/1052] Bump version to 2.11.6 PiperOrigin-RevId: 316949571 --- RELEASENOTES.md | 2 +- constants.gradle | 4 ++-- .../com/google/android/exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 905b588854..7b667aa17e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,6 +1,6 @@ # Release notes # -### 2.11.6 (not yet released) ### +### 2.11.6 (2020-06-24) ### * UI: Prevent `PlayerView` from temporarily hiding the video surface when seeking to an unprepared period within the current window. For example when diff --git a/constants.gradle b/constants.gradle index 1d7a0f0ebd..7b6298e9d4 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.5' - releaseVersionCode = 2011005 + releaseVersion = '2.11.6' + releaseVersionCode = 2011006 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 15d43c7b79..f35c3ac498 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.5"; + public static final String VERSION = "2.11.6"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.5"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.6"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011005; + public static final int VERSION_INT = 2011006; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From 0ffe74707647aef90ba9d16ade156384e9f2586b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 22 Jun 2020 09:04:34 +0100 Subject: [PATCH 0529/1052] Fix incorrect rounding of ad cue points We currently get float ad cue points from IMA, but store these as longs in microseconds. The cast from double to long would take the floor of the value, which could lead to stored ad cue points being off-by-one. Use Math.round to avoid this. ImaAdsLoader also has code to map a double AdPodInfo position (which should match a cue point) onto the corresponding ad group index by searching the long ad cue points. Match the calculation used where we map float cue points, including narrowing the position to a float first to avoid regressions if IMA SDK behavior changes to represent positions in more than float precision later, and also remove the requirement that the ad positions match exactly as a defensive measure. PiperOrigin-RevId: 317607017 --- RELEASENOTES.md | 1 + demos/main/src/main/assets/media.exolist.json | 5 ++ .../ext/ima/AdPlaybackStateFactory.java | 2 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 12 ++++- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 49 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7b667aa17e..cb745018be 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -18,6 +18,7 @@ * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded on seeking to another position ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix incorrect rounding of ad cue points. ### 2.11.5 (2020-06-05) ### diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index ac5737d195..6ffc448028 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -476,6 +476,11 @@ "name": "VMAP full, empty, full midrolls", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", "ad_tag_uri": "https://vastsynthesizer.appspot.com/empty-midroll-2" + }, + { + "name": "VMAP midroll at 1765 s", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4", + "ad_tag_uri": "https://vastsynthesizer.appspot.com/midroll-large" } ] }, diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java index 3c1b6954aa..a97307a419 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java @@ -46,7 +46,7 @@ import java.util.List; if (cuePoint == -1.0) { adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; } else { - adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); + adGroupTimesUs[adGroupIndex++] = Math.round(C.MICROS_PER_SECOND * cuePoint); } } // Cue points may be out of order, so sort them. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 37f49e317b..b8d8203f77 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -343,6 +343,8 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { * milliseconds. */ private static final long THRESHOLD_AD_PRELOAD_MS = 4000; + /** The threshold below which ad cue points are treated as matching, in microseconds. */ + private static final long THRESHOLD_AD_MATCH_US = 1000; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -1252,9 +1254,15 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { } // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. - long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); + // We receive cue points from IMA SDK as floats. This code replicates the same calculation used + // to populate adGroupTimesUs (having truncated input back to float, to avoid failures if the + // behavior of the IMA SDK changes to provide greater precision in AdPodInfo). + long adPodTimeUs = + Math.round((double) ((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { - if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) { + long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; + if (adGroupTimeUs != C.TIME_END_OF_SOURCE + && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { return adGroupIndex; } } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index fce0e34300..48c6ac210e 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -614,6 +614,55 @@ public final class ImaAdsLoaderTest { inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } + @Test + public void loadAd_withLargeAdCuePoint_updatesAdPlaybackStateWithLoadedAd() { + float midrollTimeSecs = 1_765f; + ImmutableList cuePoints = ImmutableList.of(midrollTimeSecs); + setupPlayback(CONTENT_TIMELINE, cuePoints); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + videoAdPlayer.loadAd( + TEST_AD_MEDIA_INFO, + new AdPodInfo() { + @Override + public int getTotalAds() { + return 1; + } + + @Override + public int getAdPosition() { + return 1; + } + + @Override + public boolean isBumper() { + return false; + } + + @Override + public double getMaxDuration() { + return 0; + } + + @Override + public int getPodIndex() { + return 0; + } + + @Override + public double getTimeOffset() { + return midrollTimeSecs; + } + }); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})); + } + private void setupPlayback(Timeline contentTimeline, List cuePoints) { setupPlayback( contentTimeline, From df10fdf773fae4715eed7593c04856b4fdc1e14f Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 22 Jun 2020 09:42:35 +0100 Subject: [PATCH 0530/1052] Fix handling of postrolls preloading The IMA SDK now preloads postrolls which is great as we no longer need to rely on detecting buffering at the end of the stream to trigger playing postrolls. Add in the required logic to detect the period transition to playing the postroll. Issue: #7518 PiperOrigin-RevId: 317610682 --- RELEASENOTES.md | 2 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cb745018be..b4e877f5dc 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -19,6 +19,8 @@ on seeking to another position ([#7492](https://github.com/google/ExoPlayer/issues/7492)). * Fix incorrect rounding of ad cue points. + * Fix handling of postrolls preloading + ([#7518](https://github.com/google/ExoPlayer/issues/7518)). ### 2.11.5 (2020-06-05) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index b8d8203f77..eecce40314 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1089,11 +1089,19 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { } if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { int adGroupIndex = player.getCurrentAdGroupIndex(); - // IMA hasn't called playAd yet, so fake the content position. - fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); - fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); - if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { - fakeContentProgressOffsetMs = contentDurationMs; + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { + adsLoader.contentComplete(); + if (DEBUG) { + Log.d(TAG, "adsLoader.contentComplete from period transition"); + } + sentContentComplete = true; + } else { + // IMA hasn't called playAd yet, so fake the content position. + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { + fakeContentProgressOffsetMs = contentDurationMs; + } } } } @@ -1212,7 +1220,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete"); + Log.d(TAG, "adsLoader.contentComplete from content position check"); } sentContentComplete = true; } From 67c99e1d1146ddddec14cc6811775f0d483cd4d5 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 23 Jun 2020 10:55:10 +0100 Subject: [PATCH 0531/1052] Fix resuming postrolls Postrolls would be skipped because the period duration wasn't know at the moment of resuming playback after backgrounding, so the position wouldn't be resolved to resume the postroll ad. We have the period duration stored in the AdPlaybackState, so we can use that directly. Issue: #7518 PiperOrigin-RevId: 317830418 --- .../exoplayer2/source/ads/AdPlaybackState.java | 4 +++- .../source/ads/SinglePeriodAdTimeline.java | 13 +++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 70128c78bf..3a093ca79f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -270,7 +270,9 @@ public final class AdPlaybackState { public final AdGroup[] adGroups; /** The position offset in the first unplayed ad at which to begin playback, in microseconds. */ public final long adResumePositionUs; - /** The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. */ + /** + * The duration of the content period in microseconds, if known. {@link C#TIME_UNSET} otherwise. + */ public final long contentDurationUs; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java index b5167dc173..cc82510a29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java @@ -44,23 +44,16 @@ public final class SinglePeriodAdTimeline extends ForwardingTimeline { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { timeline.getPeriod(periodIndex, period, setIds); + long durationUs = + period.durationUs == C.TIME_UNSET ? adPlaybackState.contentDurationUs : period.durationUs; period.set( period.id, period.uid, period.windowIndex, - period.durationUs, + durationUs, period.getPositionInWindowUs(), adPlaybackState); return period; } - @Override - public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { - window = super.getWindow(windowIndex, window, defaultPositionProjectionUs); - if (window.durationUs == C.TIME_UNSET) { - window.durationUs = adPlaybackState.contentDurationUs; - } - return window; - } - } From 9d8f54ab3acec723ee352aadbdcb52e7d00420cf Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 23 Jun 2020 15:11:56 +0100 Subject: [PATCH 0532/1052] Simplify MediaSourceList setup and make class final. The class was only non-final to allow mocking. Using the real class in the test works equally well. PiperOrigin-RevId: 317858805 --- .../exoplayer2/ExoPlayerImplInternal.java | 5 +- .../android/exoplayer2/MediaSourceList.java | 63 ++++++++++--------- .../exoplayer2/MediaPeriodQueueTest.java | 27 ++++---- .../exoplayer2/MediaSourceListTest.java | 6 +- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index f7abd4dd9d..441bb94d68 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -186,10 +186,7 @@ import java.util.concurrent.atomic.AtomicBoolean; internalPlaybackThread.start(); handler = clock.createHandler(internalPlaybackThread.getLooper(), this); deliverPendingMessageAtStartPositionRequired = true; - mediaSourceList = new MediaSourceList(this); - if (analyticsCollector != null) { - mediaSourceList.setAnalyticsCollector(eventHandler, analyticsCollector); - } + mediaSourceList = new MediaSourceList(/* listener= */ this, analyticsCollector, eventHandler); } public void experimental_setReleaseTimeoutMs(long releaseTimeoutMs) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java index cffad118ad..518f7bc6cb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java @@ -52,7 +52,7 @@ import java.util.Set; * *

          With the exception of the constructor, all methods are called on the playback thread. */ -/* package */ class MediaSourceList { +/* package */ final class MediaSourceList { /** Listener for source events. */ public interface MediaSourceListInfoRefreshListener { @@ -81,8 +81,20 @@ import java.util.Set; @Nullable private TransferListener mediaTransferListener; - @SuppressWarnings("initialization") - public MediaSourceList(MediaSourceListInfoRefreshListener listener) { + /** + * Creates the media source list. + * + * @param listener The {@link MediaSourceListInfoRefreshListener} to be informed of timeline + * changes. + * @param analyticsCollector An optional {@link AnalyticsCollector} to be registered for media + * source events. + * @param analyticsCollectorHandler The {@link Handler} to call {@link AnalyticsCollector} methods + * on. + */ + public MediaSourceList( + MediaSourceListInfoRefreshListener listener, + @Nullable AnalyticsCollector analyticsCollector, + Handler analyticsCollectorHandler) { mediaSourceListInfoListener = listener; shuffleOrder = new DefaultShuffleOrder(0); mediaSourceByMediaPeriod = new IdentityHashMap<>(); @@ -91,6 +103,12 @@ import java.util.Set; eventDispatcher = new MediaSourceEventListener.EventDispatcher(); childSources = new HashMap<>(); enabledMediaSourceHolders = new HashSet<>(); + if (analyticsCollector != null) { + eventDispatcher.addEventListener( + analyticsCollectorHandler, analyticsCollector, MediaSourceEventListener.class); + eventDispatcher.addEventListener( + analyticsCollectorHandler, analyticsCollector, DrmSessionEventListener.class); + } } /** @@ -100,8 +118,7 @@ import java.util.Set; * @param shuffleOrder The new shuffle order. * @return The new {@link Timeline}. */ - public final Timeline setMediaSources( - List holders, ShuffleOrder shuffleOrder) { + public Timeline setMediaSources(List holders, ShuffleOrder shuffleOrder) { removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size()); return addMediaSources(/* index= */ this.mediaSourceHolders.size(), holders, shuffleOrder); } @@ -115,7 +132,7 @@ import java.util.Set; * @param shuffleOrder The new shuffle order. * @return The new {@link Timeline}. */ - public final Timeline addMediaSources( + public Timeline addMediaSources( int index, List holders, ShuffleOrder shuffleOrder) { if (!holders.isEmpty()) { this.shuffleOrder = shuffleOrder; @@ -165,8 +182,7 @@ import java.util.Set; * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0, * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} */ - public final Timeline removeMediaSourceRange( - int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { + public Timeline removeMediaSourceRange(int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { Assertions.checkArgument(fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize()); this.shuffleOrder = shuffleOrder; removeMediaSourcesInternal(fromIndex, toIndex); @@ -185,7 +201,7 @@ import java.util.Set; * @throws IllegalArgumentException When an index is invalid, i.e. {@code currentIndex} < 0, * {@code currentIndex} >= {@link #getSize()}, {@code newIndex} < 0 */ - public final Timeline moveMediaSource(int currentIndex, int newIndex, ShuffleOrder shuffleOrder) { + public Timeline moveMediaSource(int currentIndex, int newIndex, ShuffleOrder shuffleOrder) { return moveMediaSourceRange(currentIndex, currentIndex + 1, newIndex, shuffleOrder); } @@ -228,39 +244,28 @@ import java.util.Set; } /** Clears the playlist. */ - public final Timeline clear(@Nullable ShuffleOrder shuffleOrder) { + public Timeline clear(@Nullable ShuffleOrder shuffleOrder) { this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear(); removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ getSize()); return createTimeline(); } /** Whether the playlist is prepared. */ - public final boolean isPrepared() { + public boolean isPrepared() { return isPrepared; } /** Returns the number of media sources in the playlist. */ - public final int getSize() { + public int getSize() { return mediaSourceHolders.size(); } - /** - * Sets the {@link AnalyticsCollector}. - * - * @param handler The handler on which to call the collector. - * @param analyticsCollector The analytics collector. - */ - public final void setAnalyticsCollector(Handler handler, AnalyticsCollector analyticsCollector) { - eventDispatcher.addEventListener(handler, analyticsCollector, MediaSourceEventListener.class); - eventDispatcher.addEventListener(handler, analyticsCollector, DrmSessionEventListener.class); - } - /** * Sets a new shuffle order to use when shuffling the child media sources. * * @param shuffleOrder A {@link ShuffleOrder}. */ - public final Timeline setShuffleOrder(ShuffleOrder shuffleOrder) { + public Timeline setShuffleOrder(ShuffleOrder shuffleOrder) { int size = getSize(); if (shuffleOrder.getLength() != size) { shuffleOrder = @@ -273,7 +278,7 @@ import java.util.Set; } /** Prepares the playlist. */ - public final void prepare(@Nullable TransferListener mediaTransferListener) { + public void prepare(@Nullable TransferListener mediaTransferListener) { Assertions.checkState(!isPrepared); this.mediaTransferListener = mediaTransferListener; for (int i = 0; i < mediaSourceHolders.size(); i++) { @@ -312,7 +317,7 @@ import java.util.Set; * * @param mediaPeriod The period to release. */ - public final void releasePeriod(MediaPeriod mediaPeriod) { + public void releasePeriod(MediaPeriod mediaPeriod) { MediaSourceHolder holder = Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); holder.mediaSource.releasePeriod(mediaPeriod); @@ -324,7 +329,7 @@ import java.util.Set; } /** Releases the playlist. */ - public final void release() { + public void release() { for (MediaSourceAndListener childSource : childSources.values()) { try { childSource.mediaSource.releaseSource(childSource.caller); @@ -340,14 +345,14 @@ import java.util.Set; } /** Throws any pending error encountered while loading or refreshing. */ - public final void maybeThrowSourceInfoRefreshError() throws IOException { + public void maybeThrowSourceInfoRefreshError() throws IOException { for (MediaSourceAndListener childSource : childSources.values()) { childSource.mediaSource.maybeThrowSourceInfoRefreshError(); } } /** Creates a timeline reflecting the current state of the playlist. */ - public final Timeline createTimeline() { + public Timeline createTimeline() { if (mediaSourceHolders.isEmpty()) { return Timeline.EMPTY; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index 0ca5dd60ef..efd88aacaf 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -23,18 +23,19 @@ import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; -import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.testutil.FakeShuffleOrder; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; -import java.util.Collections; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -70,12 +71,15 @@ public final class MediaPeriodQueueTest { private Allocator allocator; private MediaSourceList mediaSourceList; private FakeMediaSource fakeMediaSource; - private MediaSourceList.MediaSourceHolder mediaSourceHolder; @Before public void setUp() { mediaPeriodQueue = new MediaPeriodQueue(); - mediaSourceList = mock(MediaSourceList.class); + mediaSourceList = + new MediaSourceList( + mock(MediaSourceList.MediaSourceListInfoRefreshListener.class), + /* analyticsCollector= */ null, + Util.createHandlerForCurrentOrMainLooper()); rendererCapabilities = new RendererCapabilities[0]; trackSelector = mock(TrackSelector.class); allocator = mock(Allocator.class); @@ -408,10 +412,13 @@ public final class MediaPeriodQueueTest { private void setupTimeline(Timeline timeline) { fakeMediaSource = new FakeMediaSource(timeline); - mediaSourceHolder = new MediaSourceList.MediaSourceHolder(fakeMediaSource, false); + MediaSourceList.MediaSourceHolder mediaSourceHolder = + new MediaSourceList.MediaSourceHolder(fakeMediaSource, /* useLazyPreparation= */ false); + mediaSourceList.setMediaSources( + ImmutableList.of(mediaSourceHolder), new FakeShuffleOrder(/* length= */ 1)); mediaSourceHolder.mediaSource.prepareSourceInternal(/* mediaTransferListener */ null); - Timeline playlistTimeline = createPlaylistTimeline(); + Timeline playlistTimeline = mediaSourceList.createTimeline(); firstPeriodUid = playlistTimeline.getUidOfPeriod(/* periodIndex= */ 0); playbackInfo = @@ -443,13 +450,7 @@ public final class MediaPeriodQueueTest { SinglePeriodAdTimeline adTimeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); fakeMediaSource.setNewSourceInfo(adTimeline); - playbackInfo = playbackInfo.copyWithTimeline(createPlaylistTimeline()); - } - - private MediaSourceList.PlaylistTimeline createPlaylistTimeline() { - return new MediaSourceList.PlaylistTimeline( - Collections.singleton(mediaSourceHolder), - new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1)); + playbackInfo = playbackInfo.copyWithTimeline(mediaSourceList.createTimeline()); } private void advance() { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java index bcea053115..b3ff5e5c55 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java @@ -31,6 +31,7 @@ import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeShuffleOrder; import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -51,7 +52,10 @@ public class MediaSourceListTest { @Before public void setUp() { mediaSourceList = - new MediaSourceList(mock(MediaSourceList.MediaSourceListInfoRefreshListener.class)); + new MediaSourceList( + mock(MediaSourceList.MediaSourceListInfoRefreshListener.class), + /* analyticsCollector= */ null, + Util.createHandlerForCurrentOrMainLooper()); } @Test From 3eac5b4328d83d3ec78dc7706e89032b972cce2a Mon Sep 17 00:00:00 2001 From: gyumin Date: Tue, 23 Jun 2020 15:49:56 +0100 Subject: [PATCH 0533/1052] Move AudioAttributes to common module PiperOrigin-RevId: 317864048 --- .../exoplayer2/audio/AudioAttributes.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) rename library/{core => common}/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java (90%) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java similarity index 90% rename from library/core/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java rename to library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java index 53eed6c551..a35383ec92 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 The Android Open Source Project + * Copyright 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,14 +21,14 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; /** - * Attributes for audio playback, which configure the underlying platform - * {@link android.media.AudioTrack}. - *

          - * To set the audio attributes, create an instance using the {@link Builder} and either pass it to - * {@link com.google.android.exoplayer2.SimpleExoPlayer#setAudioAttributes(AudioAttributes)} or - * send a message of type {@link C#MSG_SET_AUDIO_ATTRIBUTES} to the audio renderers. - *

          - * This class is based on {@link android.media.AudioAttributes}, but can be used on all supported + * Attributes for audio playback, which configure the underlying platform {@link + * android.media.AudioTrack}. + * + *

          To set the audio attributes, create an instance using the {@link Builder} and either pass it + * to the player or send a message of type {@link C#MSG_SET_AUDIO_ATTRIBUTES} to the audio + * renderers. + * + *

          This class is based on {@link android.media.AudioAttributes}, but can be used on all supported * API versions. */ public final class AudioAttributes { From 05f3fd8138ea98069bde258817b9ef1274d433bc Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Tue, 23 Jun 2020 17:55:47 +0100 Subject: [PATCH 0534/1052] Add MediaParserHlsMediaChunkExtractor Which is an HlsMediaChunkExtractor based on MediaParser. PiperOrigin-RevId: 317886412 --- .../com/google/android/exoplayer2/source/hls/HlsMediaSource.java | 1 + 1 file changed, 1 insertion(+) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index e2d7e47665..c321e893bb 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -51,6 +51,7 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; From 73546231d2c454dd23b89d14bf9fb25bdce10704 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 23 Jun 2020 20:39:49 +0100 Subject: [PATCH 0535/1052] Bump ExoPlayerLibraryInfo versions to 2.11.6 PiperOrigin-RevId: 317921368 --- .../com/google/android/exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 15d43c7b79..f35c3ac498 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.5"; + public static final String VERSION = "2.11.6"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.5"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.6"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011005; + public static final int VERSION_INT = 2011006; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From 4138e28d626be428f5d84b77138f90006b233c08 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 24 Jun 2020 09:54:57 +0100 Subject: [PATCH 0536/1052] Move common gradle setup to a setting file. This removes a lot of duplication from the module configuration, avoids divergence, and makes sure that only the important differences to the default are visible in each module file. PiperOrigin-RevId: 318024823 --- common_library_config.gradle | 34 +++++++++++++++++++++++++++ core_settings.gradle | 3 +++ extensions/av1/build.gradle | 14 +---------- extensions/cast/build.gradle | 19 +-------------- extensions/cronet/build.gradle | 19 +-------------- extensions/ffmpeg/build.gradle | 18 +------------- extensions/flac/build.gradle | 19 +-------------- extensions/gvr/build.gradle | 19 ++------------- extensions/ima/build.gradle | 16 +------------ extensions/jobdispatcher/build.gradle | 19 +-------------- extensions/leanback/build.gradle | 19 ++------------- extensions/mediasession/build.gradle | 19 +-------------- extensions/okhttp/build.gradle | 20 +--------------- extensions/opus/build.gradle | 19 +-------------- extensions/rtmp/build.gradle | 19 +-------------- extensions/vp9/build.gradle | 19 +-------------- extensions/workmanager/build.gradle | 19 +-------------- library/all/build.gradle | 12 +--------- library/common/build.gradle | 25 ++------------------ library/core/build.gradle | 18 +------------- library/dash/build.gradle | 17 +------------- library/extractor/build.gradle | 17 +------------- library/hls/build.gradle | 17 +------------- library/smoothstreaming/build.gradle | 17 +------------- library/ui/build.gradle | 25 ++------------------ playbacktests/build.gradle | 20 +--------------- testdata/build.gradle | 5 +--- testutils/build.gradle | 19 +-------------- 28 files changed, 67 insertions(+), 439 deletions(-) create mode 100644 common_library_config.gradle diff --git a/common_library_config.gradle b/common_library_config.gradle new file mode 100644 index 0000000000..7be2290bc0 --- /dev/null +++ b/common_library_config.gradle @@ -0,0 +1,34 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +apply from: "$gradle.ext.exoplayerRoot/constants.gradle" +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.ext.compileSdkVersion + + defaultConfig { + minSdkVersion project.ext.minSdkVersion + targetSdkVersion project.ext.targetSdkVersion + consumerProguardFiles 'proguard-rules.txt' + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions.unitTests.includeAndroidResources = true +} diff --git a/core_settings.gradle b/core_settings.gradle index ac56933155..3672b015ca 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. def rootDir = gradle.ext.exoplayerRoot +if (!gradle.ext.has('exoplayerSettingsDir')) { + gradle.ext.exoplayerSettingsDir = rootDir +} def modulePrefix = ':' if (gradle.ext.has('exoplayerModulePrefix')) { modulePrefix += gradle.ext.exoplayerModulePrefix diff --git a/extensions/av1/build.gradle b/extensions/av1/build.gradle index d61a3a97f8..c89b80b3d5 100644 --- a/extensions/av1/build.gradle +++ b/extensions/av1/build.gradle @@ -11,22 +11,10 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - externalNativeBuild { cmake { // Debug CMake build type causes video frames to drop, diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index 58bb15dff2..4c8f648e34 100644 --- a/extensions/cast/build.gradle +++ b/extensions/cast/build.gradle @@ -11,24 +11,7 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api 'com.google.android.gms:play-services-cast-framework:18.1.0' diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index c27bc37ff0..c0f443d5df 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -11,24 +11,7 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api "com.google.android.gms:play-services-cronet:17.0.0" diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle index 26a72ae335..09d7182a30 100644 --- a/extensions/ffmpeg/build.gradle +++ b/extensions/ffmpeg/build.gradle @@ -11,29 +11,13 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - } - sourceSets.main { jniLibs.srcDir 'src/main/libs' jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio. } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle index f220d21106..9aeeb83eb3 100644 --- a/extensions/flac/build.gradle +++ b/extensions/flac/build.gradle @@ -11,24 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - sourceSets { main { jniLibs.srcDir 'src/main/libs' @@ -36,8 +21,6 @@ android { } androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle index 4e6bd76cb4..891888a0d2 100644 --- a/extensions/gvr/build.gradle +++ b/extensions/gvr/build.gradle @@ -11,24 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion 19 - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +android.defaultConfig.minSdkVersion 19 dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index 28f201e24b..b132f57baf 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -11,22 +11,10 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' // Enable multidex for androidTests. multiDexEnabled true } @@ -34,8 +22,6 @@ android { sourceSets { androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/jobdispatcher/build.gradle b/extensions/jobdispatcher/build.gradle index 05ac82ba08..df50cde8f9 100644 --- a/extensions/jobdispatcher/build.gradle +++ b/extensions/jobdispatcher/build.gradle @@ -13,24 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/leanback/build.gradle b/extensions/leanback/build.gradle index 19b4cde3bf..14ced09f12 100644 --- a/extensions/leanback/build.gradle +++ b/extensions/leanback/build.gradle @@ -11,24 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion 17 - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +android.defaultConfig.minSdkVersion 17 dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/mediasession/build.gradle b/extensions/mediasession/build.gradle index f32ef263e0..5c827084da 100644 --- a/extensions/mediasession/build.gradle +++ b/extensions/mediasession/build.gradle @@ -11,24 +11,7 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index a44e62e0e5..032fb0fded 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -11,25 +11,7 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/opus/build.gradle b/extensions/opus/build.gradle index 545b5a7af8..ba670037f6 100644 --- a/extensions/opus/build.gradle +++ b/extensions/opus/build.gradle @@ -11,24 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - sourceSets { main { jniLibs.srcDir 'src/main/libs' @@ -36,8 +21,6 @@ android { } androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/rtmp/build.gradle b/extensions/rtmp/build.gradle index 621f8b2998..3d912bebf6 100644 --- a/extensions/rtmp/build.gradle +++ b/extensions/rtmp/build.gradle @@ -11,24 +11,7 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/vp9/build.gradle b/extensions/vp9/build.gradle index ffd76d6e2f..79d85a6ac5 100644 --- a/extensions/vp9/build.gradle +++ b/extensions/vp9/build.gradle @@ -11,24 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - sourceSets { main { jniLibs.srcDir 'src/main/libs' @@ -36,8 +21,6 @@ android { } androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/workmanager/build.gradle b/extensions/workmanager/build.gradle index 36df826adb..f30461d379 100644 --- a/extensions/workmanager/build.gradle +++ b/extensions/workmanager/build.gradle @@ -13,24 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/library/all/build.gradle b/library/all/build.gradle index f78b8b2132..fa3491bb5d 100644 --- a/library/all/build.gradle +++ b/library/all/build.gradle @@ -11,17 +11,7 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api project(modulePrefix + 'library-core') diff --git a/library/common/build.gradle b/library/common/build.gradle index 27ca38d444..0e18a9c062 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -11,30 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - debug { - testCoverageEnabled = true - } - } - - testOptions.unitTests.includeAndroidResources = true -} +android.buildTypes.debug.testCoverageEnabled true dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion diff --git a/library/core/build.gradle b/library/core/build.gradle index d95629e0d7..6dc3dd647f 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -11,24 +11,10 @@ // 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. -apply plugin: 'com.android.library' -apply from: '../../constants.gradle' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - // The following argument makes the Android Test Orchestrator run its // "pm clear" command after each test invocation. This command ensures // that the app's state is completely cleared between tests. @@ -45,8 +31,6 @@ android { androidTest.assets.srcDir '../../testdata/src/test/assets/' test.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/library/dash/build.gradle b/library/dash/build.gradle index 26515c8470..82e17607f9 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -11,22 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,8 +21,6 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index 52cc193550..ffc1ce141e 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -11,22 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,8 +21,6 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/library/hls/build.gradle b/library/hls/build.gradle index e4630f6044..80ef65117b 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -11,22 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,8 +21,6 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/library/smoothstreaming/build.gradle b/library/smoothstreaming/build.gradle index e9ffcd9b5b..34fa62e096 100644 --- a/library/smoothstreaming/build.gradle +++ b/library/smoothstreaming/build.gradle @@ -11,22 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,8 +21,6 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/library/ui/build.gradle b/library/ui/build.gradle index e8c6327e25..2184443a5d 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -11,30 +11,9 @@ // 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. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - debug { - testCoverageEnabled = true - } - } - - testOptions.unitTests.includeAndroidResources = true -} +android.buildTypes.debug.testCoverageEnabled true dependencies { implementation project(modulePrefix + 'library-core') diff --git a/playbacktests/build.gradle b/playbacktests/build.gradle index 0e93b97f5e..105427250b 100644 --- a/playbacktests/build.gradle +++ b/playbacktests/build.gradle @@ -11,25 +11,7 @@ // 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. -apply from: '../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion diff --git a/testdata/build.gradle b/testdata/build.gradle index 372a01132e..2510c37e65 100644 --- a/testdata/build.gradle +++ b/testdata/build.gradle @@ -11,8 +11,5 @@ // 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. -apply from: '../constants.gradle' -apply plugin: 'com.android.library' - -android.compileSdkVersion project.ext.compileSdkVersion +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" diff --git a/testutils/build.gradle b/testutils/build.gradle index 931203b3bb..93b3acf53f 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -11,24 +11,7 @@ // 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. -apply from: '../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api 'org.mockito:mockito-core:' + mockitoVersion From 06c17f51522c21ee753f2e1f26163a6595c4bb13 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 24 Jun 2020 11:19:30 +0100 Subject: [PATCH 0537/1052] Redefine numeric Cue.line in terms of viewport lines, ignore lineAnchor Numerical lines conceptually map to a grid of lines in the viewport, with the Cue text lines being aligned to one of the viewport lines. It doesn't make sense to position a single-line cue differently based on lineAnchor when it's expected to 'snap' to a particular line on the viewport grid. So we redefine the position to be in terms of the cue lines rather than the bounds of the cue box. It's also not possible to always handle ANCHOR_TYPE_MIDDLE when lineType=NUMBER (as it relies on the number of lines in the cue being odd), so it's easier to ignore lineAnchor completely. PiperOrigin-RevId: 318034664 --- RELEASENOTES.md | 2 + demos/main/src/main/assets/media.exolist.json | 7 +++ .../google/android/exoplayer2/text/Cue.java | 60 +++++++++---------- .../exoplayer2/text/cea/Cea608Decoder.java | 5 +- .../text/webvtt/WebvttCueParser.java | 8 +-- .../text/webvtt/WebvttSubtitle.java | 11 +--- .../text/webvtt/WebvttDecoderTest.java | 13 +--- .../exoplayer2/ui/SubtitlePainter.java | 17 +++--- .../exoplayer2/ui/WebViewSubtitleOutput.java | 16 ++--- 9 files changed, 58 insertions(+), 81 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 76aee2f7f3..1b3ac65ec8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -133,6 +133,8 @@ * Add support for WebVTT's `ruby-position` CSS property. * Fix positioning for CEA-608 roll-up captions in the top half of screen ([#7475](https://github.com/google/ExoPlayer/issues/7475)). + * Redefine `Cue.lineType=LINE_TYPE_NUMBER` in terms of aligning the cue + text lines to grid of viewport lines, and ignore `Cue.lineAnchor`. * DRM: * Add support for attaching DRM sessions to clear content in the demo app. * Remove `DrmSessionManager` references from all renderers. diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index a1ea669f0e..9bdd697394 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -525,6 +525,13 @@ "subtitle_mime_type": "application/ttml+xml", "subtitle_language": "en" }, + { + "name": "WebVTT line positioning", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/numeric-lines.vtt", + "subtitle_mime_type": "text/vtt", + "subtitle_language": "en" + }, { "name": "SSA/ASS position & alignment", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4", diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java index cb3dcbfad9..98ce0dfc93 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java @@ -144,10 +144,9 @@ public final class Cue { @Nullable public final Bitmap bitmap; /** - * The position of the {@link #lineAnchor} of the cue box within the viewport in the direction - * orthogonal to the writing direction (determined by {@link #verticalType}), or {@link - * #DIMEN_UNSET}. When set, the interpretation of the value depends on the value of {@link - * #lineType}. + * The position of the cue box within the viewport in the direction orthogonal to the writing + * direction (determined by {@link #verticalType}), or {@link #DIMEN_UNSET}. When set, the + * interpretation of the value depends on the value of {@link #lineType}. * *

          The measurement direction depends on {@link #verticalType}: * @@ -167,40 +166,35 @@ public final class Cue { * *

            *
          • {@link #LINE_TYPE_FRACTION} indicates that {@link #line} is a fractional position within - * the viewport. - *
          • {@link #LINE_TYPE_NUMBER} indicates that {@link #line} is a line number, where the size - * of each line is taken to be the size of the first line of the cue. + * the viewport (measured to the part of the cue box determined by {@link #lineAnchor}). + *
          • {@link #LINE_TYPE_NUMBER} indicates that {@link #line} is a viewport line number. The + * viewport is divided into lines (each equal in size to the first line of the cue box). The + * cue box is positioned to align with the viewport lines as follows: *
              - *
            • When {@link #line} is greater than or equal to 0 lines count from the start of the - * viewport, with 0 indicating zero offset from the start edge. When {@link #line} is - * negative lines count from the end of the viewport, with -1 indicating zero offset - * from the end edge. - *
            • For horizontal text the line spacing is the height of the first line of the cue, - * and the start and end of the viewport are the top and bottom respectively. + *
            • {@link #lineAnchor}) is ignored. + *
            • When {@code line} is greater than or equal to 0 the first line in the cue box is + * aligned with a viewport line, with 0 meaning the first line of the viewport. + *
            • When {@code line} is negative the last line in the cue box is aligned with a + * viewport line, with -1 meaning the last line of the viewport. + *
            • For horizontal text the start and end of the viewport are the top and bottom + * respectively. *
            *
          - * - *

          Note that it's particularly important to consider the effect of {@link #lineAnchor} when - * using {@link #LINE_TYPE_NUMBER}. - * - *

            - *
          • {@code (line == 0 && lineAnchor == ANCHOR_TYPE_START)} positions a (potentially - * multi-line) cue at the very start of the viewport. - *
          • {@code (line == -1 && lineAnchor == ANCHOR_TYPE_END)} positions a (potentially - * multi-line) cue at the very end of the viewport. - *
          • {@code (line == 0 && lineAnchor == ANCHOR_TYPE_END)} and {@code (line == -1 && lineAnchor - * == ANCHOR_TYPE_START)} position cues entirely outside of the viewport. - *
          • {@code (line == 1 && lineAnchor == ANCHOR_TYPE_END)} positions a cue so that only the - * last line is visible at the start of the viewport. - *
          • {@code (line == -2 && lineAnchor == ANCHOR_TYPE_START)} position a cue so that only its - * first line is visible at the end of the viewport. - *
          */ public final @LineType int lineType; /** - * The cue box anchor positioned by {@link #line}. One of {@link #ANCHOR_TYPE_START}, {@link - * #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + * The cue box anchor positioned by {@link #line} when {@link #lineType} is {@link + * #LINE_TYPE_FRACTION}. + * + *

          One of: + * + *

            + *
          • {@link #ANCHOR_TYPE_START} + *
          • {@link #ANCHOR_TYPE_MIDDLE} + *
          • {@link #ANCHOR_TYPE_END} + *
          • {@link #TYPE_UNSET} + *
          * *

          For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the top, middle and bottom of @@ -584,8 +578,8 @@ public final class Cue { } /** - * Sets the position of the {@code lineAnchor} of the cue box within the viewport in the - * direction orthogonal to the writing direction. + * Sets the position of the cue box within the viewport in the direction orthogonal to the + * writing direction. * * @see Cue#line * @see Cue#lineType diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 1990cde9c2..14b5be0504 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -944,17 +944,14 @@ public final class Cea608Decoder extends CeaDecoder { break; } - int lineAnchor; int line; // Note: Row indices are in the range [1-15], Cue.line counts from 0 (top) and -1 (bottom). if (row > (BASE_ROW / 2)) { - lineAnchor = Cue.ANCHOR_TYPE_END; line = row - BASE_ROW; // Two line adjustments. The first is because line indices from the bottom of the window // start from -1 rather than 0. The second is a blank row to act as the safe area. line -= 2; } else { - lineAnchor = Cue.ANCHOR_TYPE_START; // The `row` of roll-up cues positions the bottom line (even for cues shown in the top // half of the screen), so we need to consider the number of rows in this cue. In // non-roll-up, we don't need any further adjustments because we leave the first line @@ -968,7 +965,7 @@ public final class Cea608Decoder extends CeaDecoder { Alignment.ALIGN_NORMAL, line, Cue.LINE_TYPE_NUMBER, - lineAnchor, + /* lineAnchor= */ Cue.TYPE_UNSET, position, positionAnchor, Cue.DIMEN_UNSET); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index c56fa080a0..8e804968b3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -391,13 +391,7 @@ public final class WebvttCueParser { builder.line = WebvttParserUtil.parsePercentage(s); builder.lineType = Cue.LINE_TYPE_FRACTION; } else { - int lineNumber = Integer.parseInt(s); - if (lineNumber < 0) { - // WebVTT defines line -1 as last visible row when lineAnchor is ANCHOR_TYPE_START, where-as - // Cue defines it to be the first row that's not visible. - lineNumber--; - } - builder.line = lineNumber; + builder.line = Integer.parseInt(s); builder.lineType = Cue.LINE_TYPE_NUMBER; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java index 6832033165..4a8f5a5471 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java @@ -85,16 +85,7 @@ import java.util.List; Collections.sort(cuesWithUnsetLine, (c1, c2) -> Long.compare(c1.startTimeUs, c2.startTimeUs)); for (int i = 0; i < cuesWithUnsetLine.size(); i++) { Cue cue = cuesWithUnsetLine.get(i).cue; - currentCues.add( - cue.buildUpon() - .setLine((float) (-1 - i), Cue.LINE_TYPE_NUMBER) - // WebVTT doesn't use 'line alignment' (i.e. Cue#lineAnchor) when computing position - // with snap-to-lines=true (i.e. Cue#LINE_TYPE_NUMBER) but Cue does use lineAnchor - // when describing how numeric cues should be displayed. So we have to manually set - // lineAnchor=ANCHOR_TYPE_END to avoid the bottom line of cues being off the screen. - // https://www.w3.org/TR/webvtt1/#processing-cue-settings - .setLineAnchor(Cue.ANCHOR_TYPE_END) - .build()); + currentCues.add(cue.buildUpon().setLine((float) (-1 - i), Cue.LINE_TYPE_NUMBER).build()); } return currentCues; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java index 88de97ae62..7b8c695182 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java @@ -203,10 +203,6 @@ public class WebvttDecoderTest { // Unspecified values should use WebVTT defaults assertThat(firstCue.line).isEqualTo(-1f); assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - // WebVTT specifies START as the default, but it doesn't expect this to be used if - // lineType=NUMBER so we have to override it to END in this case, otherwise the Cue will be - // displayed off the bottom of the screen. - assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(firstCue.verticalType).isEqualTo(Cue.TYPE_UNSET); assertThat(subtitle.getEventTime(2)).isEqualTo(2_345_000L); @@ -232,7 +228,7 @@ public class WebvttDecoderTest { assertThat(subtitle.getEventTime(7)).isEqualTo(7_000_000L); Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); assertThat(fourthCue.text.toString()).isEqualTo("This is the fourth subtitle."); - assertThat(fourthCue.line).isEqualTo(-11f); + assertThat(fourthCue.line).isEqualTo(-10f); assertThat(fourthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_START); assertThat(fourthCue.textAlignment).isEqualTo(Alignment.ALIGN_CENTER); // Derived from `align:middle`: @@ -280,7 +276,6 @@ public class WebvttDecoderTest { assertThat(firstCue.text.toString()).isEqualTo("Displayed at the bottom for 3 seconds."); assertThat(firstCue.line).isEqualTo(-1f); assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); List firstAndSecondCue = subtitle.getCues(subtitle.getEventTime(1)); assertThat(firstAndSecondCue).hasSize(2); @@ -288,18 +283,15 @@ public class WebvttDecoderTest { .isEqualTo("Displayed at the bottom for 3 seconds."); assertThat(firstAndSecondCue.get(0).line).isEqualTo(-1f); assertThat(firstAndSecondCue.get(0).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(firstAndSecondCue.get(0).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(firstAndSecondCue.get(1).text.toString()) .isEqualTo("Appears directly above for 1 second."); assertThat(firstAndSecondCue.get(1).line).isEqualTo(-2f); assertThat(firstAndSecondCue.get(1).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(firstAndSecondCue.get(1).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); assertThat(thirdCue.text.toString()).isEqualTo("Displayed at the bottom for 2 seconds."); assertThat(thirdCue.line).isEqualTo(-1f); assertThat(thirdCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(thirdCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); List thirdAndFourthCue = subtitle.getCues(subtitle.getEventTime(5)); assertThat(thirdAndFourthCue).hasSize(2); @@ -307,19 +299,16 @@ public class WebvttDecoderTest { .isEqualTo("Displayed at the bottom for 2 seconds."); assertThat(thirdAndFourthCue.get(0).line).isEqualTo(-1f); assertThat(thirdAndFourthCue.get(0).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(thirdAndFourthCue.get(0).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(thirdAndFourthCue.get(1).text.toString()) .isEqualTo("Appears directly above the previous cue, then replaces it after 1 second."); assertThat(thirdAndFourthCue.get(1).line).isEqualTo(-2f); assertThat(thirdAndFourthCue.get(1).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(thirdAndFourthCue.get(1).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); assertThat(fourthCue.text.toString()) .isEqualTo("Appears directly above the previous cue, then replaces it after 1 second."); assertThat(fourthCue.line).isEqualTo(-1f); assertThat(fourthCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(fourthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); } @Test diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java index d3ef1a1a87..fd7c3bffee 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java @@ -337,21 +337,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int textTop; if (cueLine != Cue.DIMEN_UNSET) { - int anchorPosition; if (cueLineType == Cue.LINE_TYPE_FRACTION) { - anchorPosition = Math.round(parentHeight * cueLine) + parentTop; + int anchorPosition = Math.round(parentHeight * cueLine) + parentTop; + textTop = + cueLineAnchor == Cue.ANCHOR_TYPE_END + ? anchorPosition - textHeight + : cueLineAnchor == Cue.ANCHOR_TYPE_MIDDLE + ? (anchorPosition * 2 - textHeight) / 2 + : anchorPosition; } else { // cueLineType == Cue.LINE_TYPE_NUMBER int firstLineHeight = textLayout.getLineBottom(0) - textLayout.getLineTop(0); if (cueLine >= 0) { - anchorPosition = Math.round(cueLine * firstLineHeight) + parentTop; + textTop = Math.round(cueLine * firstLineHeight) + parentTop; } else { - anchorPosition = Math.round((cueLine + 1) * firstLineHeight) + parentBottom; + textTop = Math.round((cueLine + 1) * firstLineHeight) + parentBottom - textHeight; } } - textTop = cueLineAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textHeight - : cueLineAnchor == Cue.ANCHOR_TYPE_MIDDLE ? (anchorPosition * 2 - textHeight) / 2 - : anchorPosition; + if (textTop + textHeight > parentBottom) { textTop = parentBottom - textHeight; } else if (textTop < parentTop) { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index ecab29ddff..70ab6bc9c9 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -180,16 +180,18 @@ import java.util.List; float linePercent; int lineTranslatePercent; - @Cue.AnchorType int lineAnchor; + int lineAnchorTranslatePercent; if (cue.line != Cue.DIMEN_UNSET) { switch (cue.lineType) { case Cue.LINE_TYPE_NUMBER: if (cue.line >= 0) { linePercent = 0; lineTranslatePercent = Math.round(cue.line) * 100; + lineAnchorTranslatePercent = 0; } else { linePercent = 100; lineTranslatePercent = Math.round(cue.line + 1) * 100; + lineAnchorTranslatePercent = -100; } break; case Cue.LINE_TYPE_FRACTION: @@ -197,18 +199,16 @@ import java.util.List; default: linePercent = cue.line * 100; lineTranslatePercent = 0; + lineAnchorTranslatePercent = + cue.verticalType == Cue.VERTICAL_TYPE_RL + ? -anchorTypeToTranslatePercent(cue.lineAnchor) + : anchorTypeToTranslatePercent(cue.lineAnchor); } - lineAnchor = cue.lineAnchor; } else { linePercent = (1.0f - bottomPaddingFraction) * 100; lineTranslatePercent = 0; - // If Cue.line == DIMEN_UNSET then ignore Cue.lineAnchor and assume ANCHOR_TYPE_END. - lineAnchor = Cue.ANCHOR_TYPE_END; + lineAnchorTranslatePercent = -100; } - int lineAnchorTranslatePercent = - cue.verticalType == Cue.VERTICAL_TYPE_RL - ? -anchorTypeToTranslatePercent(lineAnchor) - : anchorTypeToTranslatePercent(lineAnchor); String size = cue.size != Cue.DIMEN_UNSET From 54eccd3893af0a25390c7af236466ee337d96709 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 24 Jun 2020 11:42:59 +0100 Subject: [PATCH 0538/1052] Change WebViewSubtitleOutput to use em not % for line offsets The existing code moves a multi-line cue box by multiples of the height of the whole cue box (incorrect), rather than multiples of the first line of text (correct). These two are equivalent for single-line cues, which is why I didn't initially spot the problem. PiperOrigin-RevId: 318036793 --- .../exoplayer2/ui/WebViewSubtitleOutput.java | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index 70ab6bc9c9..835a4df43a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -45,6 +45,12 @@ import java.util.List; */ /* package */ final class WebViewSubtitleOutput extends FrameLayout implements SubtitleView.Output { + /** + * A hard-coded value for the line-height attribute, so we can use it to move text up and down by + * one line-height. Most browsers default 'normal' (CSS default) to 1.2 for most font families. + */ + private static final float CSS_LINE_HEIGHT = 1.2f; + /** * A {@link CanvasSubtitleOutput} used for displaying bitmap cues. * @@ -165,10 +171,12 @@ import java.util.List; + "right:0;" + "color:%s;" + "font-size:%s;" + + "line-height:%.2fem;" + "text-shadow:%s;" + "'>", HtmlUtils.toCssRgba(style.foregroundColor), convertTextSizeToCss(defaultTextSizeType, defaultTextSize), + CSS_LINE_HEIGHT, convertCaptionStyleToCssTextShadow(style))); String backgroundColorCss = HtmlUtils.toCssRgba(style.backgroundColor); @@ -178,35 +186,31 @@ import java.util.List; float positionPercent = (cue.position != Cue.DIMEN_UNSET) ? (cue.position * 100) : 50; int positionAnchorTranslatePercent = anchorTypeToTranslatePercent(cue.positionAnchor); - float linePercent; - int lineTranslatePercent; - int lineAnchorTranslatePercent; + String lineValue; + boolean lineMeasuredFromEnd = false; + int lineAnchorTranslatePercent = 0; if (cue.line != Cue.DIMEN_UNSET) { switch (cue.lineType) { case Cue.LINE_TYPE_NUMBER: if (cue.line >= 0) { - linePercent = 0; - lineTranslatePercent = Math.round(cue.line) * 100; - lineAnchorTranslatePercent = 0; + lineValue = Util.formatInvariant("%.2fem", cue.line * CSS_LINE_HEIGHT); } else { - linePercent = 100; - lineTranslatePercent = Math.round(cue.line + 1) * 100; - lineAnchorTranslatePercent = -100; + lineValue = Util.formatInvariant("%.2fem", (-cue.line - 1) * CSS_LINE_HEIGHT); + lineMeasuredFromEnd = true; } break; case Cue.LINE_TYPE_FRACTION: case Cue.TYPE_UNSET: default: - linePercent = cue.line * 100; - lineTranslatePercent = 0; + lineValue = Util.formatInvariant("%.2f%%", cue.line * 100); + lineAnchorTranslatePercent = cue.verticalType == Cue.VERTICAL_TYPE_RL ? -anchorTypeToTranslatePercent(cue.lineAnchor) : anchorTypeToTranslatePercent(cue.lineAnchor); } } else { - linePercent = (1.0f - bottomPaddingFraction) * 100; - lineTranslatePercent = 0; + lineValue = Util.formatInvariant("%.2f%%", (1.0f - bottomPaddingFraction) * 100); lineAnchorTranslatePercent = -100; } @@ -225,16 +229,16 @@ import java.util.List; String lineProperty; switch (cue.verticalType) { case Cue.VERTICAL_TYPE_LR: - lineProperty = "left"; + lineProperty = lineMeasuredFromEnd ? "right" : "left"; positionProperty = "top"; break; case Cue.VERTICAL_TYPE_RL: - lineProperty = "right"; + lineProperty = lineMeasuredFromEnd ? "left" : "right"; positionProperty = "top"; break; case Cue.TYPE_UNSET: default: - lineProperty = "top"; + lineProperty = lineMeasuredFromEnd ? "bottom" : "top"; positionProperty = "left"; } @@ -243,12 +247,12 @@ import java.util.List; int verticalTranslatePercent; if (cue.verticalType == Cue.VERTICAL_TYPE_LR || cue.verticalType == Cue.VERTICAL_TYPE_RL) { sizeProperty = "height"; - horizontalTranslatePercent = lineTranslatePercent + lineAnchorTranslatePercent; + horizontalTranslatePercent = lineAnchorTranslatePercent; verticalTranslatePercent = positionAnchorTranslatePercent; } else { sizeProperty = "width"; horizontalTranslatePercent = positionAnchorTranslatePercent; - verticalTranslatePercent = lineTranslatePercent + lineAnchorTranslatePercent; + verticalTranslatePercent = lineAnchorTranslatePercent; } html.append( @@ -256,7 +260,7 @@ import java.util.List; "

          Date: Wed, 24 Jun 2020 18:07:26 +0100 Subject: [PATCH 0541/1052] Add DefaultDrmSessionManagerTest This uses a license server implemented using MockWebServer to test DefaultDrmSessionManager and DefaultDrmSession. PiperOrigin-RevId: 318086890 --- .../drm/DefaultDrmSessionManagerTest.java | 131 ++++++++++ .../exoplayer2/testutil/FakeExoMediaDrm.java | 227 ++++++++++++++++-- .../android/exoplayer2/testutil/TestUtil.java | 22 +- 3 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java new file mode 100644 index 0000000000..6905c631c7 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.drm; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Handler; +import android.os.HandlerThread; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.Function; +import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link DefaultDrmSessionManager} and {@link DefaultDrmSession}. */ +// TODO: Test more branches: +// - Different sources for licenseServerUrl. +// - Multiple acquisitions & releases for same keys -> multiple requests. +// - Provisioning. +// - Key denial. +@RunWith(AndroidJUnit4.class) +public class DefaultDrmSessionManagerTest { + + private static final int TIMEOUT_MS = 1_000; + + private static final UUID DRM_SCHEME_UUID = + UUID.nameUUIDFromBytes(TestUtil.createByteArray(7, 8, 9)); + private static final ImmutableList DRM_SCHEME_DATAS = + ImmutableList.of( + new DrmInitData.SchemeData( + DRM_SCHEME_UUID, MimeTypes.VIDEO_MP4, /* data= */ TestUtil.createByteArray(1, 2, 3))); + private static final DrmInitData DRM_INIT_DATA = new DrmInitData(DRM_SCHEME_DATAS); + + private HandlerThread playbackThread; + private Handler playbackThreadHandler; + private MediaSourceEventDispatcher eventDispatcher; + private ConditionVariable keysLoaded; + + @Before + public void setUp() { + playbackThread = new HandlerThread("Test playback thread"); + playbackThread.start(); + playbackThreadHandler = new Handler(playbackThread.getLooper()); + eventDispatcher = new MediaSourceEventDispatcher(); + keysLoaded = TestUtil.createRobolectricConditionVariable(); + eventDispatcher.addEventListener( + playbackThreadHandler, + new DrmSessionEventListener() { + @Override + public void onDrmKeysLoaded( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + keysLoaded.open(); + } + }, + DrmSessionEventListener.class); + } + + @After + public void tearDown() { + playbackThread.quitSafely(); + } + + @Test + public void acquireSessionTriggersKeyLoadAndSessionIsOpened() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + + keysLoaded.close(); + AtomicReference drmSession = new AtomicReference<>(); + playbackThreadHandler.post( + () -> { + DefaultDrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + drmSession.set( + drmSessionManager.acquireSession( + playbackThread.getLooper(), eventDispatcher, DRM_INIT_DATA)); + }); + + keysLoaded.block(TIMEOUT_MS); + + @DrmSession.State int state = post(drmSession.get(), DrmSession::getState); + assertThat(state).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + Map keyStatus = post(drmSession.get(), DrmSession::queryKeyStatus); + assertThat(keyStatus) + .containsExactly(FakeExoMediaDrm.KEY_STATUS_KEY, FakeExoMediaDrm.KEY_STATUS_AVAILABLE); + } + + /** Call a function on {@code drmSession} on the playback thread and return the result. */ + private T post(DrmSession drmSession, Function fn) + throws InterruptedException { + AtomicReference result = new AtomicReference<>(); + ConditionVariable resultReady = TestUtil.createRobolectricConditionVariable(); + resultReady.close(); + playbackThreadHandler.post( + () -> { + result.set(fn.apply(drmSession)); + resultReady.open(); + }); + resultReady.block(TIMEOUT_MS); + return result.get(); + } +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java index 6e4b4f2437..cf422ffab3 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java @@ -20,36 +20,62 @@ import android.media.DeniedByServerException; import android.media.MediaCryptoException; import android.media.MediaDrmException; import android.media.NotProvisionedException; +import android.os.Parcel; +import android.os.Parcelable; import android.os.PersistableBundle; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.drm.ExoMediaDrm; +import com.google.android.exoplayer2.drm.MediaDrmCallback; +import com.google.android.exoplayer2.drm.MediaDrmCallbackException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Bytes; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; -/** A fake implementation of {@link ExoMediaDrm} for use in tests. */ -@RequiresApi(18) +/** + * A fake implementation of {@link ExoMediaDrm} for use in tests. + * + *

          {@link LicenseServer} can be used to respond to interactions stemming from {@link + * #getKeyRequest(byte[], List, int, HashMap)} and {@link #provideKeyResponse(byte[], byte[])}. + * + *

          Currently only supports streaming key requests. + */ +// TODO: Consider replacing this with a Robolectric ShadowMediaDrm so we can use a real +// FrameworkMediaDrm. +@RequiresApi(29) public class FakeExoMediaDrm implements ExoMediaDrm { - private static final KeyRequest DUMMY_KEY_REQUEST = - new KeyRequest(TestUtil.createByteArray(4, 5, 6), "foo.test"); - - private static final ProvisionRequest DUMMY_PROVISION_REQUEST = + public static final ProvisionRequest DUMMY_PROVISION_REQUEST = new ProvisionRequest(TestUtil.createByteArray(7, 8, 9), "bar.test"); + /** Key for use with the Map returned from {@link FakeExoMediaDrm#queryKeyStatus(byte[])}. */ + public static final String KEY_STATUS_KEY = "KEY_STATUS"; + /** Value for use with the Map returned from {@link FakeExoMediaDrm#queryKeyStatus(byte[])}. */ + public static final String KEY_STATUS_AVAILABLE = "AVAILABLE"; + /** Value for use with the Map returned from {@link FakeExoMediaDrm#queryKeyStatus(byte[])}. */ + public static final String KEY_STATUS_UNAVAILABLE = "UNAVAILABLE"; + + private static final ImmutableList VALID_KEY_RESPONSE = TestUtil.createByteList(1, 2, 3); + private static final ImmutableList KEY_DENIED_RESPONSE = TestUtil.createByteList(9, 8, 7); + private final Map byteProperties; private final Map stringProperties; private final Set> openSessionIds; + private final Set> sessionIdsWithValidKeys; private final AtomicInteger sessionIdGenerator; private int referenceCount; @@ -62,11 +88,14 @@ public class FakeExoMediaDrm implements ExoMediaDrm { byteProperties = new HashMap<>(); stringProperties = new HashMap<>(); openSessionIds = new HashSet<>(); + sessionIdsWithValidKeys = new HashSet<>(); sessionIdGenerator = new AtomicInteger(); referenceCount = 1; } + // ExoMediaCrypto implementation + @Override public void setOnEventListener(@Nullable OnEventListener listener) { // Do nothing. @@ -99,6 +128,7 @@ public class FakeExoMediaDrm implements ExoMediaDrm { @Override public void closeSession(byte[] sessionId) { Assertions.checkState(referenceCount > 0); + // TODO: Store closed session IDs too? Assertions.checkState(openSessionIds.remove(toByteList(sessionId))); } @@ -110,7 +140,18 @@ public class FakeExoMediaDrm implements ExoMediaDrm { @Nullable HashMap optionalParameters) throws NotProvisionedException { Assertions.checkState(referenceCount > 0); - return DUMMY_KEY_REQUEST; + if (keyType == KEY_TYPE_OFFLINE || keyType == KEY_TYPE_RELEASE) { + throw new UnsupportedOperationException("Offline key requests are not supported."); + } + Assertions.checkArgument(keyType == KEY_TYPE_STREAMING, "Unrecognised keyType: " + keyType); + Assertions.checkState(openSessionIds.contains(toByteList(scope))); + Assertions.checkNotNull(schemeDatas); + KeyRequestData requestData = + new KeyRequestData( + schemeDatas, + keyType, + optionalParameters != null ? optionalParameters : ImmutableMap.of()); + return new KeyRequest(requestData.toByteArray(), /* licenseServerUrl= */ ""); } @Nullable @@ -118,7 +159,13 @@ public class FakeExoMediaDrm implements ExoMediaDrm { public byte[] provideKeyResponse(byte[] scope, byte[] response) throws NotProvisionedException, DeniedByServerException { Assertions.checkState(referenceCount > 0); - return null; + List responseAsList = Bytes.asList(response); + if (responseAsList.equals(VALID_KEY_RESPONSE)) { + sessionIdsWithValidKeys.add(Bytes.asList(scope)); + } else if (responseAsList.equals(KEY_DENIED_RESPONSE)) { + throw new DeniedByServerException("Key request denied"); + } + return Util.EMPTY_BYTE_ARRAY; } @Override @@ -135,7 +182,12 @@ public class FakeExoMediaDrm implements ExoMediaDrm { @Override public Map queryKeyStatus(byte[] sessionId) { Assertions.checkState(referenceCount > 0); - return Collections.emptyMap(); + Assertions.checkState(openSessionIds.contains(toByteList(sessionId))); + return ImmutableMap.of( + KEY_STATUS_KEY, + sessionIdsWithValidKeys.contains(toByteList(sessionId)) + ? KEY_STATUS_AVAILABLE + : KEY_STATUS_UNAVAILABLE); } @Override @@ -207,13 +259,156 @@ public class FakeExoMediaDrm implements ExoMediaDrm { return FakeExoMediaCrypto.class; } - private static List toByteList(byte[] byteArray) { - List result = new ArrayList<>(byteArray.length); - for (byte b : byteArray) { - result.add(b); - } - return result; + private static ImmutableList toByteList(byte[] byteArray) { + return ImmutableList.copyOf(Bytes.asList(byteArray)); } private static class FakeExoMediaCrypto implements ExoMediaCrypto {} + + /** An license server implementation to interact with {@link FakeExoMediaDrm}. */ + public static class LicenseServer implements MediaDrmCallback { + + private final List> receivedSchemeDatas; + private final ImmutableSet> allowedSchemeDatas; + + @SafeVarargs + public static LicenseServer allowingSchemeDatas(List... schemeDatas) { + ImmutableSet.Builder> schemeDatasBuilder = + ImmutableSet.builder(); + for (List schemeData : schemeDatas) { + schemeDatasBuilder.add(ImmutableList.copyOf(schemeData)); + } + return new LicenseServer(schemeDatasBuilder.build()); + } + + private LicenseServer(ImmutableSet> allowedSchemeDatas) { + receivedSchemeDatas = new ArrayList<>(); + this.allowedSchemeDatas = allowedSchemeDatas; + } + + public ImmutableList> getReceivedSchemeDatas() { + return ImmutableList.copyOf(receivedSchemeDatas); + } + + @Override + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) + throws MediaDrmCallbackException { + return new byte[0]; + } + + @Override + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) + throws MediaDrmCallbackException { + ImmutableList schemeDatas = + KeyRequestData.fromByteArray(request.getData()).schemeDatas; + receivedSchemeDatas.add(schemeDatas); + return Bytes.toArray( + allowedSchemeDatas.contains(schemeDatas) ? VALID_KEY_RESPONSE : KEY_DENIED_RESPONSE); + } + } + + /** + * A structured set of key request fields that can be serialized into bytes by {@link + * #getKeyRequest(byte[], List, int, HashMap)} and then deserialized by {@link + * LicenseServer#executeKeyRequest(UUID, KeyRequest)}. + */ + private static class KeyRequestData implements Parcelable { + public final ImmutableList schemeDatas; + public final int type; + public final ImmutableMap optionalParameters; + + public KeyRequestData( + List schemeDatas, + int type, + Map optionalParameters) { + this.schemeDatas = ImmutableList.copyOf(schemeDatas); + this.type = type; + this.optionalParameters = ImmutableMap.copyOf(optionalParameters); + } + + public KeyRequestData(Parcel in) { + this.schemeDatas = + ImmutableList.copyOf( + in.readParcelableList( + new ArrayList<>(), DrmInitData.SchemeData.class.getClassLoader())); + this.type = in.readInt(); + + ImmutableMap.Builder optionalParameters = new ImmutableMap.Builder<>(); + List optionalParameterKeys = Assertions.checkNotNull(in.createStringArrayList()); + List optionalParameterValues = Assertions.checkNotNull(in.createStringArrayList()); + Assertions.checkArgument(optionalParameterKeys.size() == optionalParameterValues.size()); + for (int i = 0; i < optionalParameterKeys.size(); i++) { + optionalParameters.put(optionalParameterKeys.get(i), optionalParameterValues.get(i)); + } + + this.optionalParameters = optionalParameters.build(); + } + + public byte[] toByteArray() { + Parcel parcel = Parcel.obtain(); + try { + writeToParcel(parcel, /* flags= */ 0); + return parcel.marshall(); + } finally { + parcel.recycle(); + } + } + + public static KeyRequestData fromByteArray(byte[] bytes) { + Parcel parcel = Parcel.obtain(); + try { + parcel.unmarshall(bytes, 0, bytes.length); + parcel.setDataPosition(0); + return CREATOR.createFromParcel(parcel); + } finally { + parcel.recycle(); + } + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof KeyRequestData)) { + return false; + } + + KeyRequestData that = (KeyRequestData) obj; + return Objects.equals(this.schemeDatas, that.schemeDatas) + && this.type == that.type + && Objects.equals(this.optionalParameters, that.optionalParameters); + } + + @Override + public int hashCode() { + return Objects.hash(schemeDatas, type, optionalParameters); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelableList(schemeDatas, flags); + dest.writeInt(type); + dest.writeStringList(optionalParameters.keySet().asList()); + dest.writeStringList(optionalParameters.values().asList()); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public KeyRequestData createFromParcel(Parcel in) { + return new KeyRequestData(in); + } + + @Override + public KeyRequestData[] newArray(int size) { + return new KeyRequestData[size]; + } + }; + } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 1a53d300d7..8be0f305a1 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -44,6 +44,8 @@ import com.google.android.exoplayer2.util.ConditionVariable; import com.google.android.exoplayer2.util.Supplier; import com.google.android.exoplayer2.util.SystemClock; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Bytes; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -174,18 +176,28 @@ public class TestUtil { /** * Converts an array of integers in the range [0, 255] into an equivalent byte array. * - * @param intArray An array of integers, all of which must be in the range [0, 255]. + * @param bytes An array of integers, all of which must be in the range [0, 255]. * @return The equivalent byte array. */ - public static byte[] createByteArray(int... intArray) { - byte[] byteArray = new byte[intArray.length]; + public static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; for (int i = 0; i < byteArray.length; i++) { - Assertions.checkState(0x00 <= intArray[i] && intArray[i] <= 0xFF); - byteArray[i] = (byte) intArray[i]; + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; } return byteArray; } + /** + * Converts an array of integers in the range [0, 255] into an equivalent byte list. + * + * @param bytes An array of integers, all of which must be in the range [0, 255]. + * @return The equivalent byte list. + */ + public static ImmutableList createByteList(int... bytes) { + return ImmutableList.copyOf(Bytes.asList(createByteArray(bytes))); + } + /** Writes one byte long dummy test data to the file and returns it. */ public static File createTestFile(File directory, String name) throws IOException { return createTestFile(directory, name, /* length= */ 1); From d5f029315cbf33152a966bc4d7db77de65b60986 Mon Sep 17 00:00:00 2001 From: insun Date: Wed, 24 Jun 2020 23:22:56 +0100 Subject: [PATCH 0542/1052] Cleanup deprecated SimpleExoPlayerView and PlaybackControlView PiperOrigin-RevId: 318152038 --- RELEASENOTES.md | 1 + .../exoplayer2/demo/PlayerActivity.java | 4 +- .../exoplayer2/ui/PlaybackControlView.java | 54 ------------------ .../exoplayer2/ui/SimpleExoPlayerView.java | 57 ------------------- 4 files changed, 3 insertions(+), 113 deletions(-) delete mode 100644 library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java delete mode 100644 library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1b3ac65ec8..68f86832c5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -196,6 +196,7 @@ * Upgrade Truth dependency from 0.44 to 1.0. * Upgrade to JUnit 4.13-rc-2. * UI + * Remove `SimpleExoPlayerView` and `PlaybackControlView`. * Remove deperecated `exo_simple_player_view.xml` and `exo_playback_control_view.xml` from resource. * Add setter methods to `PlayerView` and `PlayerControlView` to set diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 884b811664..0ab527ad58 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -276,14 +276,14 @@ public class PlayerActivity extends AppCompatActivity } } - // PlaybackControlView.PlaybackPreparer implementation + // PlaybackPreparer implementation @Override public void preparePlayback() { player.prepare(); } - // PlaybackControlView.VisibilityListener implementation + // PlayerControlView.VisibilityListener implementation @Override public void onVisibilityChange(int visibility) { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java deleted file mode 100644 index 47d60e0233..0000000000 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 com.google.android.exoplayer2.ui; - -import android.content.Context; -import android.util.AttributeSet; - -/** @deprecated Use {@link PlayerControlView}. */ -@Deprecated -public class PlaybackControlView extends PlayerControlView { - - /** @deprecated Use {@link com.google.android.exoplayer2.ControlDispatcher}. */ - @Deprecated - public interface ControlDispatcher extends com.google.android.exoplayer2.ControlDispatcher {} - - @Deprecated - @SuppressWarnings("deprecation") - private static final class DefaultControlDispatcher - extends com.google.android.exoplayer2.DefaultControlDispatcher implements ControlDispatcher {} - /** @deprecated Use {@link com.google.android.exoplayer2.DefaultControlDispatcher}. */ - @Deprecated - @SuppressWarnings("deprecation") - public static final ControlDispatcher DEFAULT_CONTROL_DISPATCHER = new DefaultControlDispatcher(); - - public PlaybackControlView(Context context) { - super(context); - } - - public PlaybackControlView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public PlaybackControlView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public PlaybackControlView( - Context context, AttributeSet attrs, int defStyleAttr, AttributeSet playbackAttrs) { - super(context, attrs, defStyleAttr, playbackAttrs); - } -} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java deleted file mode 100644 index fae3382a32..0000000000 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 com.google.android.exoplayer2.ui; - -import android.content.Context; -import android.util.AttributeSet; -import androidx.annotation.Nullable; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; - -/** @deprecated Use {@link PlayerView}. */ -@Deprecated -public final class SimpleExoPlayerView extends PlayerView { - - public SimpleExoPlayerView(Context context) { - super(context); - } - - public SimpleExoPlayerView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public SimpleExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - /** - * Switches the view targeted by a given {@link SimpleExoPlayer}. - * - * @param player The player whose target view is being switched. - * @param oldPlayerView The old view to detach from the player. - * @param newPlayerView The new view to attach to the player. - * @deprecated Use {@link PlayerView#switchTargetView(Player, PlayerView, PlayerView)} instead. - */ - @Deprecated - @SuppressWarnings("deprecation") - public static void switchTargetView( - SimpleExoPlayer player, - @Nullable SimpleExoPlayerView oldPlayerView, - @Nullable SimpleExoPlayerView newPlayerView) { - PlayerView.switchTargetView(player, oldPlayerView, newPlayerView); - } - -} From efb4b1a5ff1cdfb868fbeb7fcca57844b564b159 Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 25 Jun 2020 13:23:18 +0100 Subject: [PATCH 0543/1052] Ignore tests until ShadowMediaCodec update Ignore two tests in AsynchronousMediaCodecBufferEnqueuerTest until the ShadowMediaCodec's behavior is updated to apply input buffer ownership. PiperOrigin-RevId: 318251859 --- .../mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java index 38fa04adbc..ed34a4eca8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java @@ -31,6 +31,7 @@ import java.io.IOException; import java.util.concurrent.atomic.AtomicLong; import org.junit.After; import org.junit.Before; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -97,6 +98,7 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { /* flags= */ 0)); } + @Ignore @Test public void queueInputBuffer_multipleTimes_limitsObjectsAllocation() { enqueuer.start(); @@ -157,6 +159,7 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { /* flags= */ 0)); } + @Ignore @Test public void queueSecureInputBuffer_multipleTimes_limitsObjectsAllocation() { enqueuer.start(); From aaa7fd114e72f6fb2c8ff55d1c18f9e0e2feb458 Mon Sep 17 00:00:00 2001 From: kimvde Date: Thu, 25 Jun 2020 13:51:26 +0100 Subject: [PATCH 0544/1052] Remove redundant default parameter in Parameter annotation PiperOrigin-RevId: 318255509 --- .../extractor/amr/AmrExtractorParameterizedTest.java | 3 +-- .../android/exoplayer2/extractor/flac/FlacExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/flv/FlvExtractorTest.java | 3 +-- .../exoplayer2/extractor/mkv/MatroskaExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java | 3 +-- .../exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java | 3 +-- .../extractor/ogg/OggExtractorParameterizedTest.java | 3 +-- .../android/exoplayer2/extractor/ts/Ac3ExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/ts/Ac4ExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/ts/AdtsExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/ts/PsExtractorTest.java | 3 +-- .../android/exoplayer2/extractor/ts/TsExtractorTest.java | 3 +-- 13 files changed, 13 insertions(+), 26 deletions(-) diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java index e79020e5c6..ccd96b3752 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java @@ -37,8 +37,7 @@ public final class AmrExtractorParameterizedTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void extractingNarrowBandSamples() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java index 94d1a5d612..c3e17693c3 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java @@ -33,8 +33,7 @@ public class FlacExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java index cde043f2d3..8460a2b52a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java @@ -32,8 +32,7 @@ public final class FlvExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java index f9aafbd1fb..592ecedf5a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java @@ -32,8 +32,7 @@ public final class MatroskaExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void mkvSample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java index a142ac1a4d..e3c9b55ddf 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java @@ -33,8 +33,7 @@ public final class Mp3ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void mp3SampleWithXingHeader() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java index c09b3b439f..af31d429bc 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java @@ -37,8 +37,7 @@ public final class FragmentedMp4ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java index 503b78624e..8150e074f1 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java @@ -32,8 +32,7 @@ public final class Mp4ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void mp4Sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java index fa9879f77a..8c5a0def07 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java @@ -36,8 +36,7 @@ public final class OggExtractorParameterizedTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void opus() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java index 5cdaf91e74..59ca03e300 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java @@ -32,8 +32,7 @@ public final class Ac3ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void ac3Sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java index c95ec27dad..974f20b8f4 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java @@ -32,8 +32,7 @@ public final class Ac4ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void ac4Sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java index 593180797d..6b0663f3b5 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java @@ -32,8 +32,7 @@ public final class AdtsExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java index 3425221775..e5c81549c9 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java @@ -32,8 +32,7 @@ public final class PsExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sampleWithH262AndMpegAudio() throws Exception { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java index 18b6978967..5ef5fc9048 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java @@ -52,8 +52,7 @@ public final class TsExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sampleWithH262AndMpegAudio() throws Exception { From a8ae98b1bf43eb3ca8c9d0e9a566a4896f39ba78 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 25 Jun 2020 15:02:10 +0100 Subject: [PATCH 0545/1052] Test that ExoPlayer can be built in a background thread PiperOrigin-RevId: 318264209 --- .../google/android/exoplayer2/ExoPlayerTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 30af89dd08..6397df6716 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -105,6 +105,7 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowAudioManager; @@ -6766,6 +6767,20 @@ public final class ExoPlayerTest { assertThat(initialMediaItems).containsExactlyElementsIn(currentMediaItems); } + // TODO: Revert to @Config(sdk = Config.ALL_SDKS) once b/143232359 is resolved + @Test + @Config(minSdk = Config.OLDEST_SDK, maxSdk = Config.TARGET_SDK) + public void buildSimpleExoPlayerInBackgroundThread_doesNotThrow() throws Exception { + Thread builderThread = new Thread(() -> new SimpleExoPlayer.Builder(context).build()); + AtomicReference builderThrow = new AtomicReference<>(); + builderThread.setUncaughtExceptionHandler((thread, throwable) -> builderThrow.set(throwable)); + + builderThread.start(); + builderThread.join(); + + assertThat(builderThrow.get()).isNull(); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { From e386b0b00aa59ad5fd2dfcb85077b90352c24439 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 25 Jun 2020 16:17:07 +0100 Subject: [PATCH 0546/1052] Automated g4 rollforward of commit ffa4ad0e77a24168430fb3ac4c2afd13b68a701a. *** Reason for rollforward *** Rollforward after making sure the handler is created, and that a test is written preventing a similar regression. *** Original change description *** Rollback of https://github.com/google/ExoPlayer/commit/b6f5a263f725089c026bb8416ade555f4f16a2bc *** Original commit *** Rollforward of commit 5612ac50a332e425dc130c3c13a139b9e6fce9ec. *** Reason for rollforward *** Rollforward after making sure the handler is created from the playback thread and not from an app thread. *** Original change description *** Rollback of https://github.com/google/ExoPlayer/commit/e1beb1d1946bb8ca94f62578aee8cbadd97b6e2b *** Original commit *** PiperOrigin-RevId: 318274400 --- RELEASENOTES.md | 1 + .../exoplayer2/DefaultRenderersFactory.java | 17 +++-- .../google/android/exoplayer2/ExoPlayer.java | 37 +++++++++++ .../android/exoplayer2/ExoPlayerImpl.java | 5 ++ .../exoplayer2/ExoPlayerImplInternal.java | 47 +++++++++++++- .../google/android/exoplayer2/Renderer.java | 32 ++++++++++ .../android/exoplayer2/SimpleExoPlayer.java | 5 ++ .../android/exoplayer2/audio/AudioSink.java | 11 ++++ .../audio/AudioTrackPositionTracker.java | 5 ++ .../exoplayer2/audio/DefaultAudioSink.java | 64 ++++++++++++++++++- .../audio/MediaCodecAudioRenderer.java | 19 ++++++ .../exoplayer2/testutil/StubExoPlayer.java | 5 ++ 12 files changed, 241 insertions(+), 7 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 68f86832c5..ba38ffd59e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -166,6 +166,7 @@ * No longer use a `MediaCodec` in audio passthrough mode. * Check `DefaultAudioSink` supports passthrough, in addition to checking the `AudioCapabilities` + * Add an experimental scheduling mode to save power in offload. ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Adjust input timestamps in `MediaCodecRenderer` to account for the Codec2 MP3 decoder having lower timestamps on the output side. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index bd56974b32..3913922c3c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -219,12 +219,20 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Sets whether audio should be played using the offload path. Audio offload disables audio - * processors (for example speed adjustment). + * Sets whether audio should be played using the offload path. + * + *

          Audio offload disables ExoPlayer audio processing, but significantly reduces the energy + * consumption of the playback when {@link + * ExoPlayer#experimental_enableOffloadScheduling(boolean)} is enabled. + * + *

          Most Android devices can only support one offload {@link android.media.AudioTrack} at a time + * and can invalidate it at any time. Thus an app can never be guaranteed that it will be able to + * play in offload. * *

          The default value is {@code false}. * - * @param enableOffload If audio offload should be used. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. * @return This factory, for convenience. */ public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { @@ -423,7 +431,8 @@ public class DefaultRenderersFactory implements RenderersFactory { * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param enableOffload If the renderer should use audio offload for all supported formats. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 9990e77f3a..736ed9f708 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -20,6 +20,8 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.audio.AudioCapabilities; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; @@ -597,4 +599,39 @@ public interface ExoPlayer extends Player { * @see #setPauseAtEndOfMediaItems(boolean) */ boolean getPauseAtEndOfMediaItems(); + + /** + * Enables audio offload scheduling, which runs ExoPlayer's main loop as rarely as possible when + * playing an audio stream using audio offload. + * + *

          Only use this scheduling mode if the player is not displaying anything to the user. For + * example when the application is in the background, or the screen is off. The player state + * (including position) is rarely updated (between 10s and 1min). + * + *

          While offload scheduling is enabled, player events may be delivered severely delayed and + * apps should not interact with the player. When returning to the foreground, disable offload + * scheduling before interacting with the player + * + *

          This mode should save significant power when the phone is playing offload audio with the + * screen off. + * + *

          This mode only has an effect when playing an audio track in offload mode, which requires all + * the following: + * + *

            + *
          • audio offload rendering is enabled in {@link + * DefaultRenderersFactory#setEnableAudioOffload} or the equivalent option passed to {@link + * com.google.android.exoplayer2.audio.DefaultAudioSink#DefaultAudioSink(AudioCapabilities, + * DefaultAudioSink.AudioProcessorChain, boolean, boolean)}. + *
          • an audio track is playing in a format which the device supports offloading (for example + * MP3 or AAC). + *
          • The {@link com.google.android.exoplayer2.audio.AudioSink} is playing with an offload + * {@link android.media.AudioTrack}. + *
          + * + *

          This method is experimental, and will be renamed or removed in a future release. + * + * @param enableOffloadScheduling Whether to enable offload scheduling. + */ + void experimental_enableOffloadScheduling(boolean enableOffloadScheduling); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 47ee6b062c..cf3fd51201 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -202,6 +202,11 @@ import java.util.concurrent.TimeoutException; internalPlayer.experimental_throwWhenStuckBuffering(); } + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + internalPlayer.experimental_enableOffloadScheduling(enableOffloadScheduling); + } + @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 441bb94d68..b02df9f050 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -95,6 +95,15 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; + /** + * Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant + * power saving. + * + *

          This value is probably too high, power measurements are needed adjust it, but as renderer + * sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s, + * this does not matter for now. + */ + private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -128,6 +137,8 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; + private boolean requestForRendererSleep; + private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; @@ -197,6 +208,13 @@ import java.util.concurrent.atomic.AtomicBoolean; throwWhenStuckBuffering = true; } + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + offloadSchedulingEnabled = enableOffloadScheduling; + if (!enableOffloadScheduling) { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + } + public void prepare() { handler.obtainMessage(MSG_PREPARE).sendToTarget(); } @@ -868,12 +886,13 @@ import java.util.concurrent.atomic.AtomicBoolean; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } + requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -883,6 +902,14 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } + private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { + if (offloadSchedulingEnabled && requestForRendererSleep) { + return; + } + + scheduleNextWork(operationStartTimeMs, intervalMs); + } + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -2051,6 +2078,24 @@ import java.util.concurrent.atomic.AtomicBoolean; joining, mayRenderStartOfStream, periodHolder.getRendererOffset()); + + renderer.handleMessage( + Renderer.MSG_SET_WAKEUP_LISTENER, + new Renderer.WakeupListener() { + @Override + public void onSleep(long wakeupDeadlineMs) { + // Do not sleep if the expected sleep time is not long enough to save significant power. + if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) { + requestForRendererSleep = true; + } + } + + @Override + public void onWakeup() { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + }); + mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index fa73f9257d..8620c2d752 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -46,6 +46,30 @@ import java.lang.annotation.RetentionPolicy; */ public interface Renderer extends PlayerMessage.Target { + /** + * Some renderers can signal when {@link #render(long, long)} should be called. + * + *

          That allows the player to sleep until the next wakeup, instead of calling {@link + * #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save + * power. + */ + interface WakeupListener { + + /** + * The renderer no longer needs to render until the next wakeup. + * + * @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be + * called. + */ + void onSleep(long wakeupDeadlineMs); + + /** + * The renderer needs to render some frames. The client should call {@link #render(long, long)} + * at its earliest convenience. + */ + void onWakeup(); + } + /** * The type of a message that can be passed to a video renderer via {@link * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or @@ -137,6 +161,14 @@ public interface Renderer extends PlayerMessage.Target { * representing the audio session ID that will be attached to the underlying audio track. */ int MSG_SET_AUDIO_SESSION_ID = 102; + /** + * A type of a message that can be passed to a {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another + * component. + * + *

          The message payload must be a {@link WakeupListener} instance. + */ + int MSG_SET_WAKEUP_LISTENER = 103; /** * Applications or extensions may define custom {@code MSG_*} constants that can be passed to * renderers. These custom constants must be greater than or equal to this value. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index db2602ea85..a2196c7201 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -633,6 +633,11 @@ public class SimpleExoPlayer extends BasePlayer C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); } + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + player.experimental_enableOffloadScheduling(enableOffloadScheduling); + } + @Override @Nullable public AudioComponent getAudioComponent() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index c4fa25d6bf..8bebd97a67 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -90,6 +90,17 @@ public interface AudioSink { * @param skipSilenceEnabled Whether skipping silences is enabled. */ void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled); + + /** Called when the offload buffer has been partially emptied. */ + default void onOffloadBufferEmptying() {} + + /** + * Called when the offload buffer has been filled completely. + * + * @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link + * #onOffloadBufferEmptying()} will be called. + */ + default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index d15fe44fc0..ae2eb92044 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -335,6 +335,11 @@ import java.lang.reflect.Method; return bufferSize - bytesPending; } + /** Returns the duration of audio that is buffered but unplayed. */ + public long getPendingBufferDurationMs(long writtenFrames) { + return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition())); + } + /** Returns whether the track is in an invalid state and must be recreated. */ public boolean isStalled(long writtenFrames) { return forceResetWorkaroundTimeMs != C.TIME_UNSET diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index bc3c321cac..27f8f3a7a2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -20,6 +20,7 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; import android.os.ConditionVariable; +import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -274,6 +275,7 @@ public final class DefaultAudioSink implements AudioSink { private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; private final boolean enableOffload; + @MonotonicNonNull private StreamEventCallbackV29 offloadStreamEventCallbackV29; @Nullable private Listener listener; /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ @@ -304,7 +306,7 @@ public final class DefaultAudioSink implements AudioSink { @Nullable private ByteBuffer inputBuffer; private int inputBufferAccessUnitCount; @Nullable private ByteBuffer outputBuffer; - private byte[] preV21OutputBuffer; + @MonotonicNonNull private byte[] preV21OutputBuffer; private int preV21OutputBufferOffset; private int drainingAudioProcessorIndex; private boolean handledEndOfStream; @@ -366,7 +368,10 @@ public final class DefaultAudioSink implements AudioSink { * be available when float output is in use. * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played * with offload and encoded audio passthrough, it will be played in offload. Audio offload is - * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). + * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android + * devices can only support one offload {@link android.media.AudioTrack} at a time and can + * invalidate it at any time. Thus an app can never be guaranteed that it will be able to play + * in offload. */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, @@ -563,6 +568,9 @@ public final class DefaultAudioSink implements AudioSink { audioTrack = Assertions.checkNotNull(configuration) .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + if (isOffloadedPlayback(audioTrack)) { + registerStreamEventCallbackV29(audioTrack); + } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { @@ -744,6 +752,16 @@ public final class DefaultAudioSink implements AudioSink { return false; } + @RequiresApi(29) + private void registerStreamEventCallbackV29(AudioTrack audioTrack) { + if (offloadStreamEventCallbackV29 == null) { + // Must be lazily initialized to receive stream event callbacks on the current (playback) + // thread as the constructor is not called in the playback thread. + offloadStreamEventCallbackV29 = new StreamEventCallbackV29(); + } + offloadStreamEventCallbackV29.register(audioTrack); + } + private void processBuffers(long avSyncPresentationTimeUs) throws WriteException { int count = activeAudioProcessors.length; int index = count; @@ -822,6 +840,15 @@ public final class DefaultAudioSink implements AudioSink { throw new WriteException(bytesWritten); } + if (playing + && listener != null + && bytesWritten < bytesRemaining + && isOffloadedPlayback(audioTrack)) { + long pendingDurationMs = + audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); + listener.onOffloadBufferFull(pendingDurationMs); + } + if (configuration.isInputPcm) { writtenPcmBytes += bytesWritten; } @@ -1040,6 +1067,9 @@ public final class DefaultAudioSink implements AudioSink { if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } + if (isOffloadedPlayback(audioTrack)) { + Assertions.checkNotNull(offloadStreamEventCallbackV29).unregister(audioTrack); + } // AudioTrack.release can take some time, so we call it on a background thread. final AudioTrack toRelease = audioTrack; audioTrack = null; @@ -1229,6 +1259,36 @@ public final class DefaultAudioSink implements AudioSink { audioFormat, audioAttributes.getAudioAttributesV21()); } + private static boolean isOffloadedPlayback(AudioTrack audioTrack) { + return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); + } + + @RequiresApi(29) + private final class StreamEventCallbackV29 extends AudioTrack.StreamEventCallback { + private final Handler handler; + + public StreamEventCallbackV29() { + handler = new Handler(); + } + + @Override + public void onDataRequest(AudioTrack track, int size) { + Assertions.checkState(track == DefaultAudioSink.this.audioTrack); + if (listener != null) { + listener.onOffloadBufferEmptying(); + } + } + + public void register(AudioTrack audioTrack) { + audioTrack.registerStreamEventCallback(handler::post, this); + } + + public void unregister(AudioTrack audioTrack) { + audioTrack.unregisterStreamEventCallback(this); + handler.removeCallbacksAndMessages(/* token= */ null); + } + } + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index a4816c5372..a2a48d6f09 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -92,6 +92,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean allowFirstBufferPositionDiscontinuity; private boolean allowPositionDiscontinuity; + @Nullable private WakeupListener wakeupListener; + /** * @param context A context. * @param mediaCodecSelector A decoder selector. @@ -696,6 +698,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case MSG_SET_AUDIO_SESSION_ID: audioSink.setAudioSessionId((Integer) message); break; + case MSG_SET_WAKEUP_LISTENER: + this.wakeupListener = (WakeupListener) message; + break; default: super.handleMessage(messageType, message); break; @@ -875,5 +880,19 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } + + @Override + public void onOffloadBufferEmptying() { + if (wakeupListener != null) { + wakeupListener.onWakeup(); + } + } + + @Override + public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { + if (wakeupListener != null) { + wakeupListener.onSleep(bufferEmptyingDeadlineMs); + } + } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index b4678cb7cf..c79a128f81 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -465,4 +465,9 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { public boolean getPauseAtEndOfMediaItems() { throw new UnsupportedOperationException(); } + + @Override + public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) { + throw new UnsupportedOperationException(); + } } From 1c018e71d40b1e5b0d125948c496f371788b792f Mon Sep 17 00:00:00 2001 From: krocard Date: Fri, 26 Jun 2020 09:14:11 +0100 Subject: [PATCH 0547/1052] Propagate format in supportsOutput *** Reason for rollforward *** Fixed dependent cl was rolled forward *** Original change description *** Rollback of https://github.com/google/ExoPlayer/commit/2aac0717d728df5511ebac5855467e83cd2d4aa0 *** Original commit *** Propagate format in supportsOutput It is needed to know if gapless is needed, as gapless offload might not be supported. *** *** PiperOrigin-RevId: 318429321 --- .../ext/ffmpeg/FfmpegAudioRenderer.java | 7 ++----- .../ext/flac/LibflacAudioRenderer.java | 2 +- .../ext/opus/LibopusAudioRenderer.java | 2 +- .../android/exoplayer2/audio/AudioSink.java | 7 ++++--- .../audio/DecoderAudioRenderer.java | 7 +++---- .../exoplayer2/audio/DefaultAudioSink.java | 16 +++++++++----- .../exoplayer2/audio/ForwardingAudioSink.java | 7 ++++--- .../audio/MediaCodecAudioRenderer.java | 12 +++++------ .../audio/DefaultAudioSinkTest.java | 21 ++++++++----------- 9 files changed, 40 insertions(+), 41 deletions(-) diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index f5e5281886..7d53c519a7 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -142,15 +142,12 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { } private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) - || supportsOutput(inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_16BIT); + return shouldUseFloatOutput(inputFormat) || supportsOutput(inputFormat, C.ENCODING_PCM_16BIT); } private boolean shouldUseFloatOutput(Format inputFormat) { Assertions.checkNotNull(inputFormat.sampleMimeType); - if (!enableFloatOutput - || !supportsOutput( - inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_FLOAT)) { + if (!enableFloatOutput || !supportsOutput(inputFormat, C.ENCODING_PCM_FLOAT)) { return false; } switch (inputFormat.sampleMimeType) { diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 9315c302cc..24a247fc76 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -100,7 +100,7 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset); pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample); } - if (!supportsOutput(format.channelCount, format.sampleRate, pcmEncoding)) { + if (!supportsOutput(format, pcmEncoding)) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { return FORMAT_UNSUPPORTED_DRM; diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index 6fe1fa8895..cafa337cf2 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -69,7 +69,7 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { if (!OpusLibrary.isAvailable() || !MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!supportsOutput(format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { + } else if (!supportsOutput(format, C.ENCODING_PCM_16BIT)) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (!drmIsSupported) { return FORMAT_UNSUPPORTED_DRM; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 8bebd97a67..8d1fa0cc4f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.audio; import android.media.AudioTrack; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.Encoding; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -187,12 +188,12 @@ public interface AudioSink { /** * Returns whether the sink supports the audio format. * - * @param channelCount The number of channels, or {@link Format#NO_VALUE} if not known. - * @param sampleRate The sample rate, or {@link Format#NO_VALUE} if not known. + * @param format The format of the audio. {@link Format#pcmEncoding} is ignored and the {@code + * encoding} argument is used instead. * @param encoding The audio encoding, or {@link Format#NO_VALUE} if not known. * @return Whether the sink supports the audio format. */ - boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding); + boolean supportsOutput(Format format, @Encoding int encoding); /** * Returns the playback position in the stream starting at zero, in microseconds, or diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index d580d0fbc0..b2eb9d9f50 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -213,11 +213,10 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media /** * Returns whether the sink supports the audio format. * - * @see AudioSink#supportsOutput(int, int, int) + * @see AudioSink#supportsOutput(Format, int) */ - protected final boolean supportsOutput( - int channelCount, int sampleRateHz, @C.Encoding int encoding) { - return audioSink.supportsOutput(channelCount, sampleRateHz, encoding); + protected final boolean supportsOutput(Format format, @C.Encoding int encoding) { + return audioSink.supportsOutput(format, encoding); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 27f8f3a7a2..6f0a3a96af 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -419,7 +419,7 @@ public final class DefaultAudioSink implements AudioSink { } @Override - public boolean supportsOutput(int channelCount, int sampleRateHz, @C.Encoding int encoding) { + public boolean supportsOutput(Format format, @C.Encoding int encoding) { if (encoding == C.ENCODING_INVALID) { return false; } @@ -431,10 +431,11 @@ public final class DefaultAudioSink implements AudioSink { return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; } if (enableOffload - && isOffloadedPlaybackSupported(channelCount, sampleRateHz, encoding, audioAttributes)) { + && isOffloadedPlaybackSupported( + format.channelCount, format.sampleRate, encoding, audioAttributes)) { return true; } - return isPassthroughPlaybackSupported(encoding, channelCount); + return isPassthroughPlaybackSupported(encoding, format.channelCount); } @Override @@ -473,8 +474,13 @@ public final class DefaultAudioSink implements AudioSink { @C.Encoding int encoding = inputEncoding; boolean useFloatOutput = enableFloatOutput - && supportsOutput(inputChannelCount, inputSampleRate, C.ENCODING_PCM_FLOAT) - && Util.isEncodingHighResolutionPcm(inputEncoding); + && Util.isEncodingHighResolutionPcm(inputEncoding) + && supportsOutput( + new Format.Builder() + .setChannelCount(inputChannelCount) + .setSampleRate(inputSampleRate) + .build(), + C.ENCODING_PCM_FLOAT); AudioProcessor[] availableAudioProcessors = useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; if (processingEnabled) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java index e0703f2aa3..f01b55a3f1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -16,7 +16,8 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.Encoding; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -35,8 +36,8 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding) { - return sink.supportsOutput(channelCount, sampleRate, encoding); + public boolean supportsOutput(Format format, @Encoding int encoding) { + return sink.supportsOutput(format, encoding); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index a2a48d6f09..02b19cbe39 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -227,10 +227,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - && !audioSink.supportsOutput( - format.channelCount, format.sampleRate, format.pcmEncoding)) - || !audioSink.supportsOutput( - format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { + && !audioSink.supportsOutput(format, format.pcmEncoding)) + || !audioSink.supportsOutput(format, C.ENCODING_PCM_16BIT)) { // Assume the decoder outputs 16-bit PCM, unless the input is raw. return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); } @@ -464,8 +462,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { // E-AC3 JOC is object-based so the output channel count is arbitrary. - if (audioSink.supportsOutput( - /* channelCount= */ Format.NO_VALUE, format.sampleRate, C.ENCODING_E_AC3_JOC)) { + Format eAc3JocFormat = format.buildUpon().setChannelCount(Format.NO_VALUE).build(); + if (audioSink.supportsOutput(eAc3JocFormat, C.ENCODING_E_AC3_JOC)) { return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC, format.codecs); } // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. @@ -473,7 +471,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @C.Encoding int encoding = MimeTypes.getEncoding(mimeType, format.codecs); - if (audioSink.supportsOutput(format.channelCount, format.sampleRate, encoding)) { + if (audioSink.supportsOutput(format, encoding)) { return encoding; } else { return C.ENCODING_INVALID; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index e916ca549f..7102bcd5ea 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -21,6 +21,7 @@ import static org.robolectric.annotation.Config.TARGET_SDK; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; @@ -50,6 +51,11 @@ public final class DefaultAudioSinkTest { private static final int SAMPLE_RATE_44_1 = 44100; private static final int TRIM_100_MS_FRAME_COUNT = 4410; private static final int TRIM_10_MS_FRAME_COUNT = 441; + private static final Format STEREO_44_1_FORMAT = + new Format.Builder() + .setChannelCount(CHANNEL_COUNT_STEREO) + .setSampleRate(SAMPLE_RATE_44_1) + .build(); private DefaultAudioSink defaultAudioSink; private ArrayAudioBufferSink arrayAudioBufferSink; @@ -201,19 +207,13 @@ public final class DefaultAudioSinkTest { @Config(minSdk = OLDEST_SDK, maxSdk = 20) @Test public void doesNotSupportFloatOutputBeforeApi21() { - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) - .isFalse(); + assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isFalse(); } @Config(minSdk = 21, maxSdk = TARGET_SDK) @Test public void supportsFloatOutputFromApi21() { - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) - .isTrue(); + assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isTrue(); } @Test @@ -221,10 +221,7 @@ public final class DefaultAudioSinkTest { DefaultAudioSink defaultAudioSink = new DefaultAudioSink( new AudioCapabilities(new int[] {C.ENCODING_AAC_LC}, 2), new AudioProcessor[0]); - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_AAC_LC)) - .isFalse(); + assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_AAC_LC)).isFalse(); } private void configureDefaultAudioSink(int channelCount) throws AudioSink.ConfigurationException { From a971d09a46a0b3d375b3a4cd10c89ac08268eb9b Mon Sep 17 00:00:00 2001 From: krocard Date: Fri, 26 Jun 2020 09:55:54 +0100 Subject: [PATCH 0548/1052] Add Offload gapless support This it is enabled only on a list of manually tested devices. The list is empty in this CL. *** Reason for rollforward *** Fixed dependent cl was rolled forward. *** Original change description *** Rollback of https://github.com/google/ExoPlayer/commit/962e08d3be3b47166d1628cd1951e115c5cc00be *** Original commit *** Add Offload gapless support Confirmed to work on a Pixel 4 after enabling the feature: `setprop vendor.audio.offload.gapless.enabled true` *** *** PiperOrigin-RevId: 318433123 --- .../exoplayer2/audio/DefaultAudioSink.java | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 6f0a3a96af..567169f5be 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -432,7 +432,12 @@ public final class DefaultAudioSink implements AudioSink { } if (enableOffload && isOffloadedPlaybackSupported( - format.channelCount, format.sampleRate, encoding, audioAttributes)) { + format.channelCount, + format.sampleRate, + encoding, + audioAttributes, + format.encoderDelay, + format.encoderPadding)) { return true; } return isPassthroughPlaybackSupported(encoding, format.channelCount); @@ -516,7 +521,13 @@ public final class DefaultAudioSink implements AudioSink { boolean useOffload = enableOffload && !isInputPcm - && isOffloadedPlaybackSupported(channelCount, sampleRate, encoding, audioAttributes); + && isOffloadedPlaybackSupported( + channelCount, + sampleRate, + encoding, + audioAttributes, + trimStartFrames, + trimEndFrames); Configuration pendingConfiguration = new Configuration( @@ -531,6 +542,8 @@ public final class DefaultAudioSink implements AudioSink { processingEnabled, canApplyPlaybackParameters, availableAudioProcessors, + trimStartFrames, + trimEndFrames, useOffload); if (isInitialized()) { this.pendingConfiguration = pendingConfiguration; @@ -576,6 +589,7 @@ public final class DefaultAudioSink implements AudioSink { .buildAudioTrack(tunneling, audioAttributes, audioSessionId); if (isOffloadedPlayback(audioTrack)) { registerStreamEventCallbackV29(audioTrack); + audioTrack.setOffloadDelayPadding(configuration.trimStartFrames, configuration.trimEndFrames); } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { @@ -653,6 +667,11 @@ public final class DefaultAudioSink implements AudioSink { // The current audio track can be reused for the new configuration. configuration = pendingConfiguration; pendingConfiguration = null; + if (isOffloadedPlayback(audioTrack)) { + audioTrack.setOffloadEndOfStream(); + audioTrack.setOffloadDelayPadding( + configuration.trimStartFrames, configuration.trimEndFrames); + } } // Re-apply playback parameters. applyPlaybackSpeedAndSkipSilence(presentationTimeUs); @@ -1255,14 +1274,24 @@ public final class DefaultAudioSink implements AudioSink { int channelCount, int sampleRateHz, @C.Encoding int encoding, - AudioAttributes audioAttributes) { + AudioAttributes audioAttributes, + int trimStartFrames, + int trimEndFrames) { if (Util.SDK_INT < 29) { return false; } int channelMask = getChannelConfig(channelCount, /* isInputPcm= */ false); AudioFormat audioFormat = getAudioFormat(sampleRateHz, channelMask, encoding); - return AudioManager.isOffloadedPlaybackSupported( - audioFormat, audioAttributes.getAudioAttributesV21()); + if (!AudioManager.isOffloadedPlaybackSupported( + audioFormat, audioAttributes.getAudioAttributesV21())) { + return false; + } + if (trimStartFrames > 0 || trimEndFrames > 0) { + // TODO(internal b/158191844): Gapless offload is not supported by all devices and there is no + // API to query its support. + return false; + } + return true; } private static boolean isOffloadedPlayback(AudioTrack audioTrack) { @@ -1579,6 +1608,8 @@ public final class DefaultAudioSink implements AudioSink { public final boolean processingEnabled; public final boolean canApplyPlaybackParameters; public final AudioProcessor[] availableAudioProcessors; + public int trimStartFrames; + public int trimEndFrames; public final boolean useOffload; public Configuration( @@ -1593,6 +1624,8 @@ public final class DefaultAudioSink implements AudioSink { boolean processingEnabled, boolean canApplyPlaybackParameters, AudioProcessor[] availableAudioProcessors, + int trimStartFrames, + int trimEndFrames, boolean useOffload) { this.isInputPcm = isInputPcm; this.inputPcmFrameSize = inputPcmFrameSize; @@ -1604,6 +1637,8 @@ public final class DefaultAudioSink implements AudioSink { this.processingEnabled = processingEnabled; this.canApplyPlaybackParameters = canApplyPlaybackParameters; this.availableAudioProcessors = availableAudioProcessors; + this.trimStartFrames = trimStartFrames; + this.trimEndFrames = trimEndFrames; this.useOffload = useOffload; // Call computeBufferSize() last as it depends on the other configuration values. From 5a72b9452bbb09580c4410807d68b129d60290e3 Mon Sep 17 00:00:00 2001 From: krocard Date: Fri, 26 Jun 2020 10:26:07 +0100 Subject: [PATCH 0549/1052] Allow offload gapless for Pixel on R Gapless offload is fixed in later R beta builds of all Pixels supporting R. On the firsts R beta builds of Pixel 4, run the following command. `setprop vendor.audio.offload.gapless.enabled true`. It can not be enabled on the first Pixel 2 and 3 beta build. PiperOrigin-RevId: 318436134 --- .../exoplayer2/audio/DefaultAudioSink.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 567169f5be..4c16d747ad 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -1286,12 +1286,19 @@ public final class DefaultAudioSink implements AudioSink { audioFormat, audioAttributes.getAudioAttributesV21())) { return false; } - if (trimStartFrames > 0 || trimEndFrames > 0) { - // TODO(internal b/158191844): Gapless offload is not supported by all devices and there is no - // API to query its support. - return false; - } - return true; + boolean noGapless = trimStartFrames == 0 && trimEndFrames == 0; + return noGapless || isOffloadGaplessSupported(); + } + + /** + * Returns if the device supports gapless in offload playback. + * + *

          Gapless offload is not supported by all devices and there is no API to query its support. As + * a result this detection is currently based on manual testing. TODO(internal b/158191844): Add + * an SDK API to query offload gapless support. + */ + private static boolean isOffloadGaplessSupported() { + return Util.SDK_INT >= 30 && Util.MODEL.startsWith("Pixel"); } private static boolean isOffloadedPlayback(AudioTrack audioTrack) { From 81b0b53a37d437a8c5933208070e92a5942dbc03 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 26 Jun 2020 10:58:34 +0100 Subject: [PATCH 0550/1052] Propagate gapless audio delay & padding. MediaCodec does not need to be re-created in the event of gapless metadata. PiperOrigin-RevId: 318439694 --- RELEASENOTES.md | 2 + .../audio/MediaCodecAudioRenderer.java | 44 +++-- .../mediacodec/MediaCodecRenderer.java | 31 +++- .../video/MediaCodecVideoRenderer.java | 13 +- .../audio/MediaCodecAudioRendererTest.java | 161 ++++++++++++++++-- .../gts/DebugRenderersFactory.java | 2 +- 6 files changed, 209 insertions(+), 44 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ba38ffd59e..2a045c354d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -170,6 +170,8 @@ ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Adjust input timestamps in `MediaCodecRenderer` to account for the Codec2 MP3 decoder having lower timestamps on the output side. + * Propagate gapless audio metadata without the need to recreate the audio + decoders. * DASH: * Enable support for embedded CEA-708. * HLS: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 02b19cbe39..8a61053574 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -310,16 +310,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected @KeepCodecResult int canKeepCodec( MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { - // TODO: We currently rely on recreating the codec when encoder delay or padding is non-zero. - // Re-creating the codec is necessary to guarantee that onOutputMediaFormatChanged is called, - // which is where encoder delay and padding are propagated to the sink. We should find a better - // way to propagate these values, and then allow the codec to be re-used in cases where this - // would otherwise be possible. - if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize - || oldFormat.encoderDelay != 0 - || oldFormat.encoderPadding != 0 - || newFormat.encoderDelay != 0 - || newFormat.encoderPadding != 0) { + if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize) { return KEEP_CODEC_RESULT_NO; } else if (codecInfo.isSeamlessAdaptationSupported( oldFormat, newFormat, /* isNewFormatComplete= */ true)) { @@ -388,9 +379,14 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @Override - protected void onOutputMediaFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) - throws ExoPlaybackException { + protected void onOutputFormatChanged(Format outputFormat) throws ExoPlaybackException { + configureOutput(outputFormat); + } + + @Override + protected void configureOutput(Format outputFormat) throws ExoPlaybackException { @C.Encoding int encoding; + MediaFormat mediaFormat; int channelCount; int sampleRate; if (passthroughFormat != null) { @@ -398,18 +394,19 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media channelCount = passthroughFormat.channelCount; sampleRate = passthroughFormat.sampleRate; } else { - if (outputMediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { - encoding = Util.getPcmEncoding(outputMediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); + mediaFormat = getCodec().getOutputFormat(); + if (mediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { + encoding = Util.getPcmEncoding(mediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); } else { - encoding = getPcmEncoding(inputFormat); + encoding = getPcmEncoding(outputFormat); } - channelCount = outputMediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); - sampleRate = outputMediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE); + channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE); } @Nullable int[] channelMap = null; - if (codecNeedsDiscardChannelsWorkaround && channelCount == 6 && inputFormat.channelCount < 6) { - channelMap = new int[inputFormat.channelCount]; - for (int i = 0; i < inputFormat.channelCount; i++) { + if (codecNeedsDiscardChannelsWorkaround && channelCount == 6 && outputFormat.channelCount < 6) { + channelMap = new int[outputFormat.channelCount]; + for (int i = 0; i < outputFormat.channelCount; i++) { channelMap[i] = i; } } @@ -420,11 +417,10 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media sampleRate, /* specifiedBufferSize= */ 0, channelMap, - inputFormat.encoderDelay, - inputFormat.encoderPadding); + outputFormat.encoderDelay, + outputFormat.encoderPadding); } catch (AudioSink.ConfigurationException e) { - // TODO(internal: b/145658993) Use outputFormat instead. - throw createRendererException(e, inputFormat); + throw createRendererException(e, outputFormat); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 465accd65f..a480e479fb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -405,6 +405,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private boolean waitingForFirstSampleInFormat; private boolean pendingOutputEndOfStream; @MediaCodecOperationMode private int mediaCodecOperationMode; + @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; private long outputStreamOffsetUs; private int pendingOutputStreamOffsetCount; @@ -622,13 +623,25 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return false; } + /** + * Sets an exception to be re-thrown by render. + * + * @param exception The exception. + */ + protected void setPendingPlaybackException(ExoPlaybackException exception) { + pendingPlaybackException = exception; + } + /** * Polls the pending output format queue for a given buffer timestamp. If a format is present, it * is removed and returned. Otherwise returns {@code null}. Subclasses should only call this * method if they are taking over responsibility for output format propagation (e.g., when using * video tunneling). + * + * @throws ExoPlaybackException Thrown if an error occurs as a result of the output format change. */ - protected final void updateOutputFormatForTime(long presentationTimeUs) { + protected final void updateOutputFormatForTime(long presentationTimeUs) + throws ExoPlaybackException { @Nullable Format format = formatQueue.pollFloor(presentationTimeUs); if (format != null) { outputFormat = format; @@ -784,6 +797,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { pendingOutputEndOfStream = false; processEndOfStream(); } + if (pendingPlaybackException != null) { + ExoPlaybackException playbackException = pendingPlaybackException; + pendingPlaybackException = null; + throw playbackException; + } + try { if (outputStreamEnded) { renderToEndOfStream(); @@ -908,6 +927,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecInfo = null; codecFormat = null; codecHasOutputMediaFormat = false; + pendingPlaybackException = null; codecOperatingRate = CODEC_OPERATING_RATE_UNSET; codecAdaptationWorkaroundMode = ADAPTATION_WORKAROUND_MODE_NEVER; codecNeedsReconfigureWorkaround = false; @@ -1490,8 +1510,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

          The default implementation is a no-op. * * @param outputFormat The new output {@link Format}. + * @throws ExoPlaybackException Thrown if an error occurs handling the new output format. */ - protected void onOutputFormatChanged(Format outputFormat) { + protected void onOutputFormatChanged(Format outputFormat) throws ExoPlaybackException { // Do nothing. } @@ -1501,8 +1522,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

          The default implementation is a no-op. * * @param outputFormat The format to configure the output with. + * @throws ExoPlaybackException Thrown if an error occurs configuring the output. */ - protected void configureOutput(Format outputFormat) { + protected void configureOutput(Format outputFormat) throws ExoPlaybackException { // Do nothing. } @@ -1538,8 +1560,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

          The default implementation is a no-op. * * @param buffer The buffer to be queued. + * @throws ExoPlaybackException Thrown if an error occurs handling the input buffer. */ - protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackException { // Do nothing. } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index afc029b7cd..6e0cb7361d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -643,11 +643,14 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { /** * Called immediately before an input buffer is queued into the codec. * + *

          In tunneling mode for pre Marshmallow, the buffer is treated as if immediately output. + * * @param buffer The buffer to be queued. + * @throws ExoPlaybackException Thrown if an error occurs handling the input buffer. */ @CallSuper @Override - protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackException { // In tunneling mode the device may do frame rate conversion, so in general we can't keep track // of the number of buffers in the codec. if (!tunneling) { @@ -891,7 +894,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } /** Called when a buffer was processed in tunneling mode. */ - protected void onProcessedTunneledBuffer(long presentationTimeUs) { + protected void onProcessedTunneledBuffer(long presentationTimeUs) throws ExoPlaybackException { updateOutputFormatForTime(presentationTimeUs); maybeNotifyVideoSizeChanged(); decoderCounters.renderedOutputBufferCount++; @@ -1808,7 +1811,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { if (presentationTimeUs == TUNNELING_EOS_PRESENTATION_TIME_US) { onProcessedTunneledEndOfStream(); } else { - onProcessedTunneledBuffer(presentationTimeUs); + try { + onProcessedTunneledBuffer(presentationTimeUs); + } catch (ExoPlaybackException e) { + setPendingPlaybackException(e); + } } } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java index 9741200dfb..717d6a50d1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.audio; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; @@ -25,13 +26,12 @@ import android.os.SystemClock; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RendererConfiguration; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.util.MimeTypes; @@ -61,6 +61,7 @@ public class MediaCodecAudioRendererTest { .build(); private MediaCodecAudioRenderer mediaCodecAudioRenderer; + private MediaCodecSelector mediaCodecSelector; @Mock private AudioSink audioSink; @@ -72,7 +73,7 @@ public class MediaCodecAudioRendererTest { when(audioSink.handleBuffer(any(), anyLong(), anyInt())).thenReturn(true); - MediaCodecSelector mediaCodecSelector = + mediaCodecSelector = new MediaCodecSelector() { @Override public List getDecoderInfos( @@ -98,17 +99,13 @@ public class MediaCodecAudioRendererTest { /* enableDecoderFallback= */ false, /* eventHandler= */ null, /* eventListener= */ null, - audioSink) { - @Override - protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) - throws DecoderQueryException { - return RendererCapabilities.create(FORMAT_HANDLED); - } - }; + audioSink); } @Test - public void render_configuresAudioSink() throws Exception { + public void render_configuresAudioSink_afterFormatChange() throws Exception { + Format changedFormat = AUDIO_AAC.buildUpon().setSampleRate(48_000).setEncoderDelay(400).build(); + FakeSampleStream fakeSampleStream = new FakeSampleStream( /* format= */ AUDIO_AAC, @@ -117,11 +114,17 @@ public class MediaCodecAudioRendererTest { /* firstSampleTimeUs= */ 0, /* timeUsIncrement= */ 50, new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(changedFormat), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), FakeSampleStreamItem.END_OF_STREAM_ITEM); mediaCodecAudioRenderer.enable( RendererConfiguration.DEFAULT, - new Format[] {AUDIO_AAC}, + new Format[] {AUDIO_AAC, changedFormat}, fakeSampleStream, /* positionUs= */ 0, /* joining= */ false, @@ -148,5 +151,139 @@ public class MediaCodecAudioRendererTest { /* outputChannels= */ null, AUDIO_AAC.encoderDelay, AUDIO_AAC.encoderPadding); + + verify(audioSink) + .configure( + changedFormat.pcmEncoding, + changedFormat.channelCount, + changedFormat.sampleRate, + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null, + changedFormat.encoderDelay, + changedFormat.encoderPadding); + } + + @Test + public void render_configuresAudioSink_afterGaplessFormatChange() throws Exception { + Format changedFormat = + AUDIO_AAC.buildUpon().setEncoderDelay(400).setEncoderPadding(232).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ AUDIO_AAC, + DrmSessionManager.DUMMY, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(changedFormat), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + + mediaCodecAudioRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {AUDIO_AAC, changedFormat}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + + mediaCodecAudioRenderer.start(); + mediaCodecAudioRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.setCurrentStreamFinal(); + + int positionUs = 500; + do { + mediaCodecAudioRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 250; + } while (!mediaCodecAudioRenderer.isEnded()); + + verify(audioSink) + .configure( + AUDIO_AAC.pcmEncoding, + AUDIO_AAC.channelCount, + AUDIO_AAC.sampleRate, + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null, + AUDIO_AAC.encoderDelay, + AUDIO_AAC.encoderPadding); + + verify(audioSink) + .configure( + changedFormat.pcmEncoding, + changedFormat.channelCount, + changedFormat.sampleRate, + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null, + changedFormat.encoderDelay, + changedFormat.encoderPadding); + } + + @Test + public void render_throwsExoPlaybackExceptionJustOnce_whenSet() throws Exception { + MediaCodecAudioRenderer exceptionThrowingRenderer = + new MediaCodecAudioRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* eventHandler= */ null, + /* eventListener= */ null) { + @Override + protected void onOutputFormatChanged(Format outputFormat) throws ExoPlaybackException { + super.onOutputFormatChanged(outputFormat); + if (!outputFormat.equals(AUDIO_AAC)) { + setPendingPlaybackException( + ExoPlaybackException.createForRenderer( + new AudioSink.ConfigurationException("Test"), + "rendererName", + /* rendererIndex= */ 0, + outputFormat, + FORMAT_HANDLED)); + } + } + }; + + Format changedFormat = AUDIO_AAC.buildUpon().setSampleRate(32_000).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ AUDIO_AAC, + DrmSessionManager.DUMMY, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + + exceptionThrowingRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {AUDIO_AAC, changedFormat}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + + exceptionThrowingRenderer.start(); + exceptionThrowingRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + exceptionThrowingRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + + // Simulating the exception being thrown when not traceable back to render. + exceptionThrowingRenderer.onOutputFormatChanged(changedFormat); + + assertThrows( + ExoPlaybackException.class, + () -> + exceptionThrowingRenderer.render( + /* positionUs= */ 500, SystemClock.elapsedRealtime() * 1000)); + + // Doesn't throw an exception because it's cleared after being thrown in the previous call to + // render. + exceptionThrowingRenderer.render(/* positionUs= */ 750, SystemClock.elapsedRealtime() * 1000); } } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java index 28a0c05440..797cf7b988 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java @@ -142,7 +142,7 @@ import java.util.ArrayList; } @Override - protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackException { super.onQueueInputBuffer(buffer); insertTimestamp(buffer.timeUs); maybeShiftTimestampsList(); From eb9de7a120240d34f3cd40bacdffa5860dfa782a Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 26 Jun 2020 13:34:05 +0100 Subject: [PATCH 0551/1052] Fix postroll content complete notifications On reaching the end of the content we would notify content complete and skip unplayed ads, causing a timeline change. That timeline change was handled in a way that caused a further timeline change in the 2.11.6 release, where we don't yet deduplicate no-op Timeline changes, causing repeated timeline changes indefinitely. At tip-of-tree, the timeline wouldn't refresh repeatedly. However the code for sending content complete at the point of transitioning to play a preloaded postroll ad was not correct in that it didn't mark previous ads as skipped. Instead they happened to be marked as skipped later on due to the timeline change handling content completion code triggering again. Fix this by only marking ads as skipped when content completes once, to avoid the duplicate timeline change, and moving the skipped ad marking so it happens in the same place as notifying content complete. PiperOrigin-RevId: 318454908 --- RELEASENOTES.md | 2 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 46 +++++++++---------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2a045c354d..2408914392 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -215,6 +215,8 @@ manipulation API. * Demo app: Retain previous position in list of samples. * Add Guava dependency. +* IMA extension: Fix the way 'content complete' is handled to avoid repeatedly + refreshing the timeline after playback ends. ### 2.11.6 (2020-06-19) ### diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 1fc30ba9a6..8c1e29d3c2 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1035,7 +1035,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING && playWhenReady) { - checkForContentComplete(); + ensureSentContentCompleteIfAtEndOfStream(); } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); if (adMediaInfo == null) { @@ -1057,15 +1057,8 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { return; } if (!playingAd && !player.isPlayingAd()) { - checkForContentComplete(); - if (sentContentComplete) { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); - } - } - updateAdPlaybackState(); - } else if (!timeline.isEmpty()) { + ensureSentContentCompleteIfAtEndOfStream(); + if (!sentContentComplete && !timeline.isEmpty()) { long positionMs = getContentPeriodPositionMs(player, timeline, period); timeline.getPeriod(/* periodIndex= */ 0, period); int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); @@ -1099,11 +1092,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { int adGroupIndex = player.getCurrentAdGroupIndex(); if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { - adsLoader.contentComplete(); - if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete from period transition"); - } - sentContentComplete = true; + sendContentComplete(); } else { // IMA hasn't called playAd yet, so fake the content position. fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); @@ -1221,20 +1210,31 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { updateAdPlaybackState(); } - private void checkForContentComplete() { - long positionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); + private void ensureSentContentCompleteIfAtEndOfStream() { if (!sentContentComplete && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { - adsLoader.contentComplete(); - if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete from content position check"); - } - sentContentComplete = true; + && getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period) + + THRESHOLD_END_OF_CONTENT_MS + >= contentDurationMs) { + sendContentComplete(); } } + private void sendContentComplete() { + adsLoader.contentComplete(); + sentContentComplete = true; + if (DEBUG) { + Log.d(TAG, "adsLoader.contentComplete"); + } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); + } + } + updateAdPlaybackState(); + } + private void updateAdPlaybackState() { // Ignore updates while detached. When a player is attached it will receive the latest state. if (eventListener != null) { From ce8bb2680213da567798affe96bfdd6ca643f91b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 26 Jun 2020 13:34:05 +0100 Subject: [PATCH 0552/1052] Fix postroll content complete notifications On reaching the end of the content we would notify content complete and skip unplayed ads, causing a timeline change. That timeline change was handled in a way that caused a further timeline change in the 2.11.6 release, where we don't yet deduplicate no-op Timeline changes, causing repeated timeline changes indefinitely. At tip-of-tree, the timeline wouldn't refresh repeatedly. However the code for sending content complete at the point of transitioning to play a preloaded postroll ad was not correct in that it didn't mark previous ads as skipped. Instead they happened to be marked as skipped later on due to the timeline change handling content completion code triggering again. Fix this by only marking ads as skipped when content completes once, to avoid the duplicate timeline change, and moving the skipped ad marking so it happens in the same place as notifying content complete. PiperOrigin-RevId: 318454908 --- RELEASENOTES.md | 4 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 46 +++++++++---------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b4e877f5dc..b890defb5d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,6 +1,10 @@ # Release notes # ### 2.11.6 (2020-06-24) ### +* IMA extension: Fix the way 'content complete' is handled to avoid repeatedly + refreshing the timeline after playback ends. + +### 2.11.6 (2020-06-19) ### * UI: Prevent `PlayerView` from temporarily hiding the video surface when seeking to an unprepared period within the current window. For example when diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index eecce40314..5436bb192c 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1026,7 +1026,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING && playWhenReady) { - checkForContentComplete(); + ensureSentContentCompleteIfAtEndOfStream(); } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); if (adMediaInfo == null) { @@ -1048,15 +1048,8 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { return; } if (!playingAd && !player.isPlayingAd()) { - checkForContentComplete(); - if (sentContentComplete) { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); - } - } - updateAdPlaybackState(); - } else if (!timeline.isEmpty()) { + ensureSentContentCompleteIfAtEndOfStream(); + if (!sentContentComplete && !timeline.isEmpty()) { long positionMs = getContentPeriodPositionMs(player, timeline, period); timeline.getPeriod(/* periodIndex= */ 0, period); int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); @@ -1090,11 +1083,7 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { int adGroupIndex = player.getCurrentAdGroupIndex(); if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { - adsLoader.contentComplete(); - if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete from period transition"); - } - sentContentComplete = true; + sendContentComplete(); } else { // IMA hasn't called playAd yet, so fake the content position. fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); @@ -1212,20 +1201,31 @@ public final class ImaAdsLoader implements Player.EventListener, AdsLoader { updateAdPlaybackState(); } - private void checkForContentComplete() { - long positionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); + private void ensureSentContentCompleteIfAtEndOfStream() { if (!sentContentComplete && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { - adsLoader.contentComplete(); - if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete from content position check"); - } - sentContentComplete = true; + && getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period) + + THRESHOLD_END_OF_CONTENT_MS + >= contentDurationMs) { + sendContentComplete(); } } + private void sendContentComplete() { + adsLoader.contentComplete(); + sentContentComplete = true; + if (DEBUG) { + Log.d(TAG, "adsLoader.contentComplete"); + } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); + } + } + updateAdPlaybackState(); + } + private void updateAdPlaybackState() { // Ignore updates while detached. When a player is attached it will receive the latest state. if (eventListener != null) { From b9511697f6830926f01dce212d5b0bfac593c502 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 26 Jun 2020 16:14:29 +0100 Subject: [PATCH 0553/1052] Document specialties of the Player interface when timeline is empty According to the discussion in . PiperOrigin-RevId: 318473575 --- .../com/google/android/exoplayer2/Player.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 1a136b2d30..3541806293 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -841,6 +841,8 @@ public interface Player { * C#TIME_UNSET} is passed, the default position of the given window is used. In any case, if * {@code startWindowIndex} is set to {@link C#INDEX_UNSET}, this parameter is ignored and the * position is not reset at all. + * @throws IllegalSeekPositionException If the provided {@code windowIndex} is not within the + * bounds of the list of media items. */ void setMediaItems(List mediaItems, int startWindowIndex, long startPositionMs); @@ -1064,6 +1066,8 @@ public interface Player { * * @param windowIndex The index of the window whose associated default position should be seeked * to. + * @throws IllegalSeekPositionException If the player has a non-empty timeline and the provided + * {@code windowIndex} is not within the bounds of the current timeline. */ void seekToDefaultPosition(int windowIndex); @@ -1223,21 +1227,25 @@ public interface Player { int getCurrentPeriodIndex(); /** - * Returns the index of the window currently being played. + * Returns the index of the current {@link Timeline.Window window} in the {@link + * #getCurrentTimeline() timeline}, or the prospective window index if the {@link + * #getCurrentTimeline() current timeline} is empty. */ int getCurrentWindowIndex(); /** * Returns the index of the next timeline window to be played, which may depend on the current * repeat mode and whether shuffle mode is enabled. Returns {@link C#INDEX_UNSET} if the window - * currently being played is the last window. + * currently being played is the last window or if the {@link #getCurrentTimeline() current + * timeline} is empty. */ int getNextWindowIndex(); /** * Returns the index of the previous timeline window to be played, which may depend on the current * repeat mode and whether shuffle mode is enabled. Returns {@link C#INDEX_UNSET} if the window - * currently being played is the first window. + * currently being played is the first window or if the {@link #getCurrentTimeline() current + * timeline} is empty. */ int getPreviousWindowIndex(); @@ -1262,7 +1270,11 @@ public interface Player { */ long getDuration(); - /** Returns the playback position in the current content window or ad, in milliseconds. */ + /** + * Returns the playback position in the current content window or ad, in milliseconds, or the + * prospective position in milliseconds if the {@link #getCurrentTimeline() current timeline} is + * empty. + */ long getCurrentPosition(); /** From 4227c8f19fdb6b4ead8d8379dcc77577013a6009 Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 26 Jun 2020 17:24:49 +0100 Subject: [PATCH 0554/1052] Move MP4 getTrackSampleTables to AtomParsers PiperOrigin-RevId: 318485946 --- .../exoplayer2/extractor/mp4/AtomParsers.java | 102 +++++++++++++----- .../extractor/mp4/Mp4Extractor.java | 40 +------ 2 files changed, 78 insertions(+), 64 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 3cf858558a..c5395c94af 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -43,7 +43,7 @@ import java.util.Collections; import java.util.List; import org.checkerframework.checker.nullness.compatqual.NullableType; -/** Utility methods for parsing MP4 format atom payloads according to ISO 14496-12. */ +/** Utility methods for parsing MP4 format atom payloads according to ISO/IEC 14496-12. */ @SuppressWarnings({"ConstantField"}) /* package */ final class AtomParsers { @@ -83,7 +83,54 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private static final byte[] opusMagic = Util.getUtf8Bytes("OpusHead"); /** - * Parses a trak atom (defined in 14496-12). + * Parse the trak atoms in a moov atom (defined in ISO/IEC 14496-12). + * + * @param moov Moov atom to decode. + * @param gaplessInfoHolder Holder to populate with gapless playback information. + * @param ignoreEditLists Whether to ignore any edit lists in the trak boxes. + * @param isQuickTime True for QuickTime media. False otherwise. + * @return A list of {@link TrackSampleTable} instances. + * @throws ParserException Thrown if the trak atoms can't be parsed. + */ + public static List parseTraks( + Atom.ContainerAtom moov, + GaplessInfoHolder gaplessInfoHolder, + boolean ignoreEditLists, + boolean isQuickTime) + throws ParserException { + List trackSampleTables = new ArrayList<>(); + for (int i = 0; i < moov.containerChildren.size(); i++) { + Atom.ContainerAtom atom = moov.containerChildren.get(i); + if (atom.type != Atom.TYPE_trak) { + continue; + } + @Nullable + Track track = + parseTrak( + atom, + moov.getLeafAtomOfType(Atom.TYPE_mvhd), + /* duration= */ C.TIME_UNSET, + /* drmInitData= */ null, + ignoreEditLists, + isQuickTime); + if (track == null) { + continue; + } + Atom.ContainerAtom stblAtom = + atom.getContainerAtomOfType(Atom.TYPE_mdia) + .getContainerAtomOfType(Atom.TYPE_minf) + .getContainerAtomOfType(Atom.TYPE_stbl); + TrackSampleTable trackSampleTable = parseStbl(track, stblAtom, gaplessInfoHolder); + if (trackSampleTable.sampleCount == 0) { + continue; + } + trackSampleTables.add(trackSampleTable); + } + return trackSampleTables; + } + + /** + * Parses a trak atom (defined in ISO/IEC 14496-12). * * @param trak Atom to decode. * @param mvhd Movie header atom, used to get the timescale. @@ -93,6 +140,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @param ignoreEditLists Whether to ignore any edit lists in the trak box. * @param isQuickTime True for QuickTime media. False otherwise. * @return A {@link Track} instance, or {@code null} if the track's type isn't supported. + * @throws ParserException Thrown if the trak atom can't be parsed. */ @Nullable public static Track parseTrak( @@ -145,7 +193,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses an stbl atom (defined in 14496-12). + * Parses an stbl atom (defined in ISO/IEC 14496-12). * * @param track Track to which this sample table corresponds. * @param stblAtom stbl (sample table) atom to decode. @@ -275,11 +323,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (ctts != null) { while (remainingSamplesAtTimestampOffset == 0 && remainingTimestampOffsetChanges > 0) { remainingSamplesAtTimestampOffset = ctts.readUnsignedIntToInt(); - // The BMFF spec (ISO 14496-12) states that sample offsets should be unsigned integers - // in version 0 ctts boxes, however some streams violate the spec and use signed - // integers instead. It's safe to always decode sample offsets as signed integers here, - // because unsigned integers will still be parsed correctly (unless their top bit is - // set, which is never true in practice because sample offsets are always small). + // The BMFF spec (ISO/IEC 14496-12) states that sample offsets should be unsigned + // integers in version 0 ctts boxes, however some streams violate the spec and use + // signed integers instead. It's safe to always decode sample offsets as signed integers + // here, because unsigned integers will still be parsed correctly (unless their top bit + // is set, which is never true in practice because sample offsets are always small). timestampOffset = ctts.readInt(); remainingTimestampOffsetChanges--; } @@ -308,7 +356,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; remainingSamplesAtTimestampDelta--; if (remainingSamplesAtTimestampDelta == 0 && remainingTimestampDeltaChanges > 0) { remainingSamplesAtTimestampDelta = stts.readUnsignedIntToInt(); - // The BMFF spec (ISO 14496-12) states that sample deltas should be unsigned integers + // The BMFF spec (ISO/IEC 14496-12) states that sample deltas should be unsigned integers // in stts boxes, however some streams violate the spec and use signed integers instead. // See https://github.com/google/ExoPlayer/issues/3384. It's safe to always decode sample // deltas as signed integers here, because unsigned integers will still be parsed @@ -382,12 +430,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; track, offsets, sizes, maximumSize, timestamps, flags, durationUs); } - // See the BMFF spec (ISO 14496-12) subsection 8.6.6. Edit lists that require prerolling from a - // sync sample after reordering are not supported. Partial audio sample truncation is only - // supported in edit lists with one edit that removes less than MAX_GAPLESS_TRIM_SIZE_SAMPLES - // samples from the start/end of the track. This implementation handles simple - // discarding/delaying of samples. The extractor may place further restrictions on what edited - // streams are playable. + // See the BMFF spec (ISO/IEC 14496-12) subsection 8.6.6. Edit lists that require prerolling + // from a sync sample after reordering are not supported. Partial audio sample truncation is + // only supported in edit lists with one edit that removes less than + // MAX_GAPLESS_TRIM_SIZE_SAMPLES samples from the start/end of the track. This implementation + // handles simple discarding/delaying of samples. The extractor may place further restrictions + // on what edited streams are playable. if (track.editListDurations.length == 1 && track.type == C.TRACK_TYPE_AUDIO @@ -556,7 +604,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (hdlrAtom == null || keysAtom == null || ilstAtom == null - || AtomParsers.parseHdlr(hdlrAtom.data) != TYPE_mdta) { + || parseHdlr(hdlrAtom.data) != TYPE_mdta) { // There isn't enough information to parse the metadata, or the handler type is unexpected. return null; } @@ -627,7 +675,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses a mvhd atom (defined in 14496-12), returning the timescale for the movie. + * Parses a mvhd atom (defined in ISO/IEC 14496-12), returning the timescale for the movie. * * @param mvhd Contents of the mvhd atom to be parsed. * @return Timescale for the movie. @@ -641,7 +689,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses a tkhd atom (defined in 14496-12). + * Parses a tkhd atom (defined in ISO/IEC 14496-12). * * @return An object containing the parsed data. */ @@ -726,11 +774,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses an mdhd atom (defined in 14496-12). + * Parses an mdhd atom (defined in ISO/IEC 14496-12). * * @param mdhd The mdhd atom to decode. * @return A pair consisting of the media timescale defined as the number of time units that pass - * in one second, and the language code. + * in one second, and the language code. */ private static Pair parseMdhd(ParsableByteArray mdhd) { mdhd.setPosition(Atom.HEADER_SIZE); @@ -749,7 +797,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses a stsd atom (defined in 14496-12). + * Parses a stsd atom (defined in ISO/IEC 14496-12). * * @param stsd The stsd atom to decode. * @param trackId The track's identifier in its container. @@ -1025,7 +1073,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses the edts atom (defined in 14496-12 subsection 8.6.5). + * Parses the edts atom (defined in ISO/IEC 14496-12 subsection 8.6.5). * * @param edtsAtom edts (edit box) atom to decode. * @return Pair of edit list durations and edit list media times, or {@code null} if they are not @@ -1287,7 +1335,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private static Pair<@NullableType String, byte @NullableType []> parseEsdsFromParent( ParsableByteArray parent, int position) { parent.setPosition(position + Atom.HEADER_SIZE + 4); - // Start of the ES_Descriptor (defined in 14496-1) + // Start of the ES_Descriptor (defined in ISO/IEC 14496-1) parent.skipBytes(1); // ES_Descriptor tag parseExpandableClassSize(parent); parent.skipBytes(2); // ES_ID @@ -1303,11 +1351,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; parent.skipBytes(2); } - // Start of the DecoderConfigDescriptor (defined in 14496-1) + // Start of the DecoderConfigDescriptor (defined in ISO/IEC 14496-1) parent.skipBytes(1); // DecoderConfigDescriptor tag parseExpandableClassSize(parent); - // Set the MIME type based on the object type indication (14496-1 table 5). + // Set the MIME type based on the object type indication (ISO/IEC 14496-1 table 5). int objectTypeIndication = parent.readUnsignedByte(); String mimeType = getMimeTypeFromMp4ObjectType(objectTypeIndication); if (MimeTypes.AUDIO_MPEG.equals(mimeType) @@ -1448,9 +1496,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return null; } - /** - * Parses the size of an expandable class, as specified by ISO 14496-1 subsection 8.3.3. - */ + /** Parses the size of an expandable class, as specified by ISO/IEC 14496-1 subsection 8.3.3. */ private static int parseExpandableClassSize(ParsableByteArray data) { int currentByte = data.readUnsignedByte(); int size = currentByte & 0x7F; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index 48c7e3e122..bc822c8212 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static com.google.android.exoplayer2.extractor.mp4.AtomParsers.parseTraks; + import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -406,8 +408,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { } boolean ignoreEditLists = (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0; - ArrayList trackSampleTables = - getTrackSampleTables(moov, gaplessInfoHolder, ignoreEditLists); + List trackSampleTables = + parseTraks(moov, gaplessInfoHolder, ignoreEditLists, isQuickTime); int trackCount = trackSampleTables.size(); for (int i = 0; i < trackCount; i++) { @@ -448,40 +450,6 @@ public final class Mp4Extractor implements Extractor, SeekMap { extractorOutput.seekMap(this); } - private ArrayList getTrackSampleTables( - ContainerAtom moov, GaplessInfoHolder gaplessInfoHolder, boolean ignoreEditLists) - throws ParserException { - ArrayList trackSampleTables = new ArrayList<>(); - for (int i = 0; i < moov.containerChildren.size(); i++) { - Atom.ContainerAtom atom = moov.containerChildren.get(i); - if (atom.type != Atom.TYPE_trak) { - continue; - } - @Nullable - Track track = - AtomParsers.parseTrak( - atom, - moov.getLeafAtomOfType(Atom.TYPE_mvhd), - /* duration= */ C.TIME_UNSET, - /* drmInitData= */ null, - ignoreEditLists, - isQuickTime); - if (track == null) { - continue; - } - Atom.ContainerAtom stblAtom = - atom.getContainerAtomOfType(Atom.TYPE_mdia) - .getContainerAtomOfType(Atom.TYPE_minf) - .getContainerAtomOfType(Atom.TYPE_stbl); - TrackSampleTable trackSampleTable = AtomParsers.parseStbl(track, stblAtom, gaplessInfoHolder); - if (trackSampleTable.sampleCount == 0) { - continue; - } - trackSampleTables.add(trackSampleTable); - } - return trackSampleTables; - } - /** * Attempts to extract the next sample in the current mdat atom for the specified track. * From c9717f67ea7ccd647188210c83ec582e98e4c5c7 Mon Sep 17 00:00:00 2001 From: olly Date: Sun, 28 Jun 2020 18:28:48 +0100 Subject: [PATCH 0555/1052] Push all Downloader networking onto the executor Issue: Issue: #6978 PiperOrigin-RevId: 318710782 --- .../offline/DefaultDownloaderFactory.java | 5 +- .../exoplayer2/offline/DownloadManager.java | 2 +- .../exoplayer2/offline/Downloader.java | 25 +- .../offline/ProgressiveDownloader.java | 103 +++--- .../exoplayer2/offline/SegmentDownloader.java | 301 ++++++++++++++--- .../upstream/cache/CacheDataSource.java | 22 -- .../upstream/cache/CacheWriter.java | 20 +- .../exoplayer2/util/RunnableFutureTask.java | 172 ++++++++++ .../upstream/cache/CacheDataSourceTest.java | 4 - .../upstream/cache/CacheWriterTest.java | 9 - .../util/RunnableFutureTaskTest.java | 302 ++++++++++++++++++ .../source/dash/offline/DashDownloader.java | 39 ++- .../source/hls/offline/HlsDownloader.java | 8 +- 13 files changed, 851 insertions(+), 161 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java index 0b7434c339..67e2bd8c77 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.Assertions; import java.lang.reflect.Constructor; import java.util.List; import java.util.concurrent.Executor; @@ -94,8 +95,8 @@ public class DefaultDownloaderFactory implements DownloaderFactory { */ public DefaultDownloaderFactory( CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - this.cacheDataSourceFactory = cacheDataSourceFactory; - this.executor = executor; + this.cacheDataSourceFactory = Assertions.checkNotNull(cacheDataSourceFactory); + this.executor = Assertions.checkNotNull(executor); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 50df4a0e8a..5b80b64ad8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -1331,7 +1331,7 @@ public final class DownloadManager { } } } catch (InterruptedException e) { - // The task was canceled. Do nothing. + Thread.currentThread().interrupt(); } catch (Exception e) { finalException = e; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java index 56f8c0ce8d..8e51bf685e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.offline; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import java.io.IOException; +import java.util.concurrent.CancellationException; /** Downloads and removes a piece of content. */ public interface Downloader { @@ -44,15 +45,29 @@ public interface Downloader { /** * Downloads the content. * + *

          If downloading fails, this method can be called again to resume the download. It cannot be + * called again after the download has been {@link #cancel canceled}. + * + *

          If downloading is canceled whilst this method is executing, then it is expected that it will + * return reasonably quickly. However, there are no guarantees about how the method will return, + * meaning that it can return without throwing, or by throwing any of its documented exceptions. + * The caller must use its own knowledge about whether downloading has been canceled to determine + * whether this is why the method has returned, rather than relying on the method returning in a + * particular way. + * * @param progressListener A listener to receive progress updates, or {@code null}. - * @throws DownloadException Thrown if the content cannot be downloaded. - * @throws IOException If the download did not complete successfully. + * @throws IOException If the download failed to complete successfully. + * @throws InterruptedException If the download was interrupted. + * @throws CancellationException If the download was canceled. */ - void download(@Nullable ProgressListener progressListener) throws IOException; + void download(@Nullable ProgressListener progressListener) + throws IOException, InterruptedException; /** - * Cancels the download operation and prevents future download operations from running. The caller - * should also interrupt the downloading thread immediately after calling this method. + * Permanently cancels the downloading by this downloader. The caller should also interrupt the + * downloading thread immediately after calling this method. + * + *

          Once canceled, {@link #download} cannot be called again. */ void cancel(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index dd251dad26..09fa444cf3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -25,16 +25,24 @@ import com.google.android.exoplayer2.upstream.cache.CacheWriter; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.RunnableFutureTask; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** A downloader for progressive media streams. */ public final class ProgressiveDownloader implements Downloader { + private final Executor executor; private final DataSpec dataSpec; private final CacheDataSource dataSource; - private final AtomicBoolean isCanceled; + @Nullable private final PriorityTaskManager priorityTaskManager; + + @Nullable private ProgressListener progressListener; + private volatile @MonotonicNonNull RunnableFutureTask downloadRunnable; + private volatile boolean isCanceled; /** @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory)} instead. */ @SuppressWarnings("deprecation") @@ -84,6 +92,7 @@ public final class ProgressiveDownloader implements Downloader { */ public ProgressiveDownloader( MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + this.executor = Assertions.checkNotNull(executor); Assertions.checkNotNull(mediaItem.playbackProperties); dataSpec = new DataSpec.Builder() @@ -92,40 +101,65 @@ public final class ProgressiveDownloader implements Downloader { .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) .build(); dataSource = cacheDataSourceFactory.createDataSourceForDownloading(); - isCanceled = new AtomicBoolean(); + priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); } @Override - public void download(@Nullable ProgressListener progressListener) throws IOException { - CacheWriter cacheWriter = - new CacheWriter( - dataSource, - dataSpec, - /* allowShortContent= */ false, - isCanceled, - /* temporaryBuffer= */ null, - progressListener == null ? null : new ProgressForwarder(progressListener)); + public void download(@Nullable ProgressListener progressListener) + throws IOException, InterruptedException { + this.progressListener = progressListener; + if (downloadRunnable == null) { + CacheWriter cacheWriter = + new CacheWriter( + dataSource, + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + this::onProgress); + downloadRunnable = + new RunnableFutureTask() { + @Override + protected Void doWork() throws IOException { + cacheWriter.cache(); + return null; + } + + @Override + protected void cancelWork() { + cacheWriter.cancel(); + } + }; + } - @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager(); if (priorityTaskManager != null) { priorityTaskManager.add(C.PRIORITY_DOWNLOAD); } try { boolean finished = false; - while (!finished && !isCanceled.get()) { + while (!finished && !isCanceled) { if (priorityTaskManager != null) { - priorityTaskManager.proceed(dataSource.getUpstreamPriority()); + priorityTaskManager.proceed(C.PRIORITY_DOWNLOAD); } + executor.execute(downloadRunnable); try { - cacheWriter.cache(); + downloadRunnable.get(); finished = true; - } catch (PriorityTooLowException e) { - // The next loop iteration will block until the task is able to proceed. + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof PriorityTooLowException) { + // The next loop iteration will block until the task is able to proceed. + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(cause); + } } } - } catch (InterruptedException e) { - // The download was canceled. } finally { + // If the main download thread was interrupted as part of cancelation, then it's possible that + // the runnable is still doing work. We need to wait until it's finished before returning. + downloadRunnable.blockUntilFinished(); if (priorityTaskManager != null) { priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); } @@ -134,7 +168,11 @@ public final class ProgressiveDownloader implements Downloader { @Override public void cancel() { - isCanceled.set(true); + isCanceled = true; + RunnableFutureTask downloadRunnable = this.downloadRunnable; + if (downloadRunnable != null) { + downloadRunnable.cancel(/* interruptIfRunning= */ true); + } } @Override @@ -142,21 +180,14 @@ public final class ProgressiveDownloader implements Downloader { dataSource.getCache().removeResource(dataSource.getCacheKeyFactory().buildCacheKey(dataSpec)); } - private static final class ProgressForwarder implements CacheWriter.ProgressListener { - - private final ProgressListener progressListener; - - public ProgressForwarder(ProgressListener progressListener) { - this.progressListener = progressListener; - } - - @Override - public void onProgress(long contentLength, long bytesCached, long newBytesCached) { - float percentDownloaded = - contentLength == C.LENGTH_UNSET || contentLength == 0 - ? C.PERCENTAGE_UNSET - : ((bytesCached * 100f) / contentLength); - progressListener.onProgress(contentLength, bytesCached, percentDownloaded); + private void onProgress(long contentLength, long bytesCached, long newBytesCached) { + if (progressListener == null) { + return; } + float percentDownloaded = + contentLength == C.LENGTH_UNSET || contentLength == 0 + ? C.PERCENTAGE_UNSET + : ((bytesCached * 100f) / contentLength); + progressListener.onProgress(contentLength, bytesCached, percentDownloaded); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 7360b65f70..d824dfd1e3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -33,14 +33,17 @@ import com.google.android.exoplayer2.upstream.cache.ContentMetadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.RunnableFutureTask; import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * Base class for multi segment stream downloaders. @@ -77,8 +80,22 @@ public abstract class SegmentDownloader> impleme private final Parser manifestParser; private final ArrayList streamKeys; private final CacheDataSource.Factory cacheDataSourceFactory; + private final Cache cache; + private final CacheKeyFactory cacheKeyFactory; + @Nullable private final PriorityTaskManager priorityTaskManager; private final Executor executor; - private final AtomicBoolean isCanceled; + + /** + * The currently active runnables. + * + *

          Note: Only the {@link #download} thread is permitted to modify this list. Modifications, as + * well as the iteration on the {@link #cancel} thread, must be synchronized on the instance for + * thread safety. Iterations on the {@link #download} thread do not need to be synchronized, and + * should not be synchronized because doing so can erroneously block {@link #cancel}. + */ + private final ArrayList> activeRunnables; + + private volatile boolean isCanceled; /** * @param mediaItem The {@link MediaItem} to be downloaded. @@ -100,28 +117,31 @@ public abstract class SegmentDownloader> impleme this.streamKeys = new ArrayList<>(mediaItem.playbackProperties.streamKeys); this.cacheDataSourceFactory = cacheDataSourceFactory; this.executor = executor; - isCanceled = new AtomicBoolean(); + cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); + cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); + priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); + activeRunnables = new ArrayList<>(); } @Override - public final void download(@Nullable ProgressListener progressListener) throws IOException { - @Nullable - PriorityTaskManager priorityTaskManager = - cacheDataSourceFactory.getUpstreamPriorityTaskManager(); + public final void download(@Nullable ProgressListener progressListener) + throws IOException, InterruptedException { + ArrayDeque pendingSegments = new ArrayDeque<>(); + ArrayDeque recycledRunnables = new ArrayDeque<>(); if (priorityTaskManager != null) { priorityTaskManager.add(C.PRIORITY_DOWNLOAD); } try { - Cache cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); - CacheKeyFactory cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForDownloading(); - // Get the manifest and all of the segments. - M manifest = getManifest(dataSource, manifestDataSpec); + M manifest = getManifest(dataSource, manifestDataSpec, /* removing= */ false); if (!streamKeys.isEmpty()) { manifest = manifest.copy(streamKeys); } - List segments = getSegments(dataSource, manifest, /* allowIncompleteList= */ false); + List segments = getSegments(dataSource, manifest, /* removing= */ false); + + // Sort the segments so that we download media in the right order from the start of the + // content, and merge segments where possible to minimize the number of server round trips. Collections.sort(segments); mergeSegments(segments, cacheKeyFactory); @@ -169,34 +189,76 @@ public abstract class SegmentDownloader> impleme bytesDownloaded, segmentsDownloaded) : null; - byte[] temporaryBuffer = new byte[BUFFER_SIZE_BYTES]; - int segmentIndex = 0; - while (!isCanceled.get() && segmentIndex < segments.size()) { + pendingSegments.addAll(segments); + while (!isCanceled && !pendingSegments.isEmpty()) { + // Block until there aren't any higher priority tasks. if (priorityTaskManager != null) { - priorityTaskManager.proceed(dataSource.getUpstreamPriority()); + priorityTaskManager.proceed(C.PRIORITY_DOWNLOAD); } - CacheWriter cacheWriter = - new CacheWriter( - dataSource, - segments.get(segmentIndex).dataSpec, - /* allowShortContent= */ false, - isCanceled, - temporaryBuffer, - progressNotifier); - try { - cacheWriter.cache(); - segmentIndex++; - if (progressNotifier != null) { - progressNotifier.onSegmentDownloaded(); + + // Create and execute a runnable to download the next segment. + CacheDataSource segmentDataSource; + byte[] temporaryBuffer; + if (!recycledRunnables.isEmpty()) { + SegmentDownloadRunnable recycledRunnable = recycledRunnables.removeFirst(); + segmentDataSource = recycledRunnable.dataSource; + temporaryBuffer = recycledRunnable.temporaryBuffer; + } else { + segmentDataSource = cacheDataSourceFactory.createDataSourceForDownloading(); + temporaryBuffer = new byte[BUFFER_SIZE_BYTES]; + } + Segment segment = pendingSegments.removeFirst(); + SegmentDownloadRunnable downloadRunnable = + new SegmentDownloadRunnable( + segment, segmentDataSource, progressNotifier, temporaryBuffer); + addActiveRunnable(downloadRunnable); + executor.execute(downloadRunnable); + + // Clean up runnables that have finished. + for (int j = activeRunnables.size() - 1; j >= 0; j--) { + SegmentDownloadRunnable activeRunnable = (SegmentDownloadRunnable) activeRunnables.get(j); + // Only block until the runnable has finished if we don't have any more pending segments + // to start. If we do have pending segments to start then only process the runnable if + // it's already finished. + if (pendingSegments.isEmpty() || activeRunnable.isDone()) { + try { + activeRunnable.get(); + removeActiveRunnable(j); + recycledRunnables.addLast(activeRunnable); + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof PriorityTooLowException) { + // We need to schedule this segment again in a future loop iteration. + pendingSegments.addFirst(activeRunnable.segment); + removeActiveRunnable(j); + recycledRunnables.addLast(activeRunnable); + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(cause); + } + } } - } catch (PriorityTooLowException e) { - // The next loop iteration will block until the task is able to proceed, then try and - // download the same segment again. } + + // Don't move on to the next segment until the runnable for this segment has started. This + // drip feeds runnables to the executor, rather than providing them all up front. + downloadRunnable.blockUntilStarted(); } - } catch (InterruptedException e) { - // The download was canceled. } finally { + // If one of the runnables has thrown an exception, then it's possible there are other active + // runnables still doing work. We need to wait until they finish before exiting this method. + // Cancel them to speed this up. + for (int i = 0; i < activeRunnables.size(); i++) { + activeRunnables.get(i).cancel(/* interruptIfRunning= */ true); + } + // Wait until the runnables have finished. In addition to the failure case, we also need to + // do this for the case where the main download thread was interrupted as part of cancelation. + for (int i = activeRunnables.size() - 1; i >= 0; i--) { + activeRunnables.get(i).blockUntilFinished(); + removeActiveRunnable(i); + } if (priorityTaskManager != null) { priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); } @@ -205,21 +267,26 @@ public abstract class SegmentDownloader> impleme @Override public void cancel() { - isCanceled.set(true); + synchronized (activeRunnables) { + isCanceled = true; + for (int i = 0; i < activeRunnables.size(); i++) { + activeRunnables.get(i).cancel(/* interruptIfRunning= */ true); + } + } } @Override public final void remove() { - Cache cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); - CacheKeyFactory cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForRemovingDownload(); try { - M manifest = getManifest(dataSource, manifestDataSpec); - List segments = getSegments(dataSource, manifest, true); + M manifest = getManifest(dataSource, manifestDataSpec, /* removing= */ true); + List segments = getSegments(dataSource, manifest, /* removing= */ true); for (int i = 0; i < segments.size(); i++) { cache.removeResource(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); } - } catch (IOException e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { // Ignore exceptions when removing. } finally { // Always attempt to remove the manifest. @@ -232,34 +299,121 @@ public abstract class SegmentDownloader> impleme /** * Loads and parses a manifest. * - * @param dataSource The {@link DataSource} through which to load. * @param dataSpec The manifest {@link DataSpec}. - * @return The manifest. - * @throws IOException If an error occurs reading data. + * @param removing Whether the manifest is being loaded as part of the download being removed. + * @return The loaded manifest. + * @throws InterruptedException If the thread on which the method is called is interrupted. + * @throws IOException If an error occurs during execution. */ - protected final M getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { - return ParsingLoadable.load(dataSource, manifestParser, dataSpec, C.DATA_TYPE_MANIFEST); + protected final M getManifest(DataSource dataSource, DataSpec dataSpec, boolean removing) + throws InterruptedException, IOException { + return execute( + new RunnableFutureTask() { + @Override + protected M doWork() throws IOException { + return ParsingLoadable.load(dataSource, manifestParser, dataSpec, C.DATA_TYPE_MANIFEST); + } + }, + removing); } /** - * Returns a list of all downloadable {@link Segment}s for a given manifest. + * Executes the provided {@link RunnableFutureTask}. + * + * @param runnable The {@link RunnableFutureTask} to execute. + * @param removing Whether the execution is part of the download being removed. + * @return The result. + * @throws InterruptedException If the thread on which the method is called is interrupted. + * @throws IOException If an error occurs during execution. + */ + protected final T execute(RunnableFutureTask runnable, boolean removing) + throws InterruptedException, IOException { + if (removing) { + runnable.run(); + try { + return runnable.get(); + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(e); + } + } + } + while (true) { + if (isCanceled) { + throw new InterruptedException(); + } + // Block until there aren't any higher priority tasks. + if (priorityTaskManager != null) { + priorityTaskManager.proceed(C.PRIORITY_DOWNLOAD); + } + addActiveRunnable(runnable); + executor.execute(runnable); + try { + return runnable.get(); + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof PriorityTooLowException) { + // The next loop iteration will block until the task is able to proceed. + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(e); + } + } finally { + // We don't want to return for as long as the runnable might still be doing work. + runnable.blockUntilFinished(); + removeActiveRunnable(runnable); + } + } + } + + /** + * Returns a list of all downloadable {@link Segment}s for a given manifest. Any required data + * should be loaded using {@link #getManifest} or {@link #execute}. * * @param dataSource The {@link DataSource} through which to load any required data. * @param manifest The manifest containing the segments. - * @param allowIncompleteList Whether to continue in the case that a load error prevents all - * segments from being listed. If true then a partial segment list will be returned. If false - * an {@link IOException} will be thrown. + * @param removing Whether the segments are being obtained as part of a removal. If true then a + * partial segment list is returned in the case that a load error prevents all segments from + * being listed. If false then an {@link IOException} will be thrown in this case. * @return The list of downloadable {@link Segment}s. - * @throws IOException Thrown if {@code allowPartialIndex} is false and a load error occurs, or if - * the media is not in a form that allows for its segments to be listed. + * @throws IOException Thrown if {@code allowPartialIndex} is false and an execution error occurs, + * or if the media is not in a form that allows for its segments to be listed. */ - protected abstract List getSegments( - DataSource dataSource, M manifest, boolean allowIncompleteList) throws IOException; + protected abstract List getSegments(DataSource dataSource, M manifest, boolean removing) + throws IOException, InterruptedException; protected static DataSpec getCompressibleDataSpec(Uri uri) { return new DataSpec.Builder().setUri(uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); } + private void addActiveRunnable(RunnableFutureTask runnable) + throws InterruptedException { + synchronized (activeRunnables) { + if (isCanceled) { + throw new InterruptedException(); + } + activeRunnables.add(runnable); + } + } + + private void removeActiveRunnable(RunnableFutureTask runnable) { + synchronized (activeRunnables) { + activeRunnables.remove(runnable); + } + } + + private void removeActiveRunnable(int index) { + synchronized (activeRunnables) { + activeRunnables.remove(index); + } + } + private static void mergeSegments(List segments, CacheKeyFactory keyFactory) { HashMap lastIndexByCacheKey = new HashMap<>(); int nextOutIndex = 0; @@ -298,6 +452,47 @@ public abstract class SegmentDownloader> impleme && dataSpec1.httpRequestHeaders.equals(dataSpec2.httpRequestHeaders); } + private static final class SegmentDownloadRunnable extends RunnableFutureTask { + + public final Segment segment; + public final CacheDataSource dataSource; + @NullableType private final ProgressNotifier progressNotifier; + public final byte[] temporaryBuffer; + private final CacheWriter cacheWriter; + + public SegmentDownloadRunnable( + Segment segment, + CacheDataSource dataSource, + @NullableType ProgressNotifier progressNotifier, + byte[] temporaryBuffer) { + this.segment = segment; + this.dataSource = dataSource; + this.progressNotifier = progressNotifier; + this.temporaryBuffer = temporaryBuffer; + this.cacheWriter = + new CacheWriter( + dataSource, + segment.dataSpec, + /* allowShortContent= */ false, + temporaryBuffer, + progressNotifier); + } + + @Override + protected Void doWork() throws IOException { + cacheWriter.cache(); + if (progressNotifier != null) { + progressNotifier.onSegmentDownloaded(); + } + return null; + } + + @Override + protected void cancelWork() { + cacheWriter.cancel(); + } + } + private static final class ProgressNotifier implements CacheWriter.ProgressListener { private final ProgressListener progressListener; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index e1e2e5194b..7398ff58a2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -376,8 +376,6 @@ public final class CacheDataSource implements DataSource { @Nullable private final DataSource cacheWriteDataSource; private final DataSource upstreamDataSource; private final CacheKeyFactory cacheKeyFactory; - @Nullable private final PriorityTaskManager upstreamPriorityTaskManager; - private final int upstreamPriority; @Nullable private final EventListener eventListener; private final boolean blockOnCache; @@ -513,8 +511,6 @@ public final class CacheDataSource implements DataSource { this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0; this.ignoreCacheForUnsetLengthRequests = (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0; - this.upstreamPriority = upstreamPriority; - this.upstreamPriorityTaskManager = upstreamPriorityTaskManager; if (upstreamDataSource != null) { if (upstreamPriorityTaskManager != null) { upstreamDataSource = @@ -543,24 +539,6 @@ public final class CacheDataSource implements DataSource { return cacheKeyFactory; } - /** - * Returns the {@link PriorityTaskManager} used when there's a cache miss and requests need to be - * made to the upstream {@link DataSource}, or {@code null} if there is none. - */ - @Nullable - public PriorityTaskManager getUpstreamPriorityTaskManager() { - return upstreamPriorityTaskManager; - } - - /** - * Returns the priority used when there's a cache miss and requests need to be made to the - * upstream {@link DataSource}. The priority is only used if the source has a {@link - * PriorityTaskManager}. - */ - public int getUpstreamPriority() { - return upstreamPriority; - } - @Override public void addTransferListener(TransferListener transferListener) { cacheReadDataSource.addTransferListener(transferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java index ee44b0dc51..8ea2b4e280 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java @@ -25,7 +25,6 @@ import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowExce import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.io.InterruptedIOException; -import java.util.concurrent.atomic.AtomicBoolean; /** Caching related utility methods. */ public final class CacheWriter { @@ -52,7 +51,6 @@ public final class CacheWriter { private final Cache cache; private final DataSpec dataSpec; private final boolean allowShortContent; - private final AtomicBoolean isCanceled; private final String cacheKey; private final byte[] temporaryBuffer; @Nullable private final ProgressListener progressListener; @@ -62,6 +60,8 @@ public final class CacheWriter { private long endPosition; private long bytesCached; + private volatile boolean isCanceled; + /** * @param dataSource A {@link CacheDataSource} that writes to the target cache. * @param dataSpec Defines the data to be written. @@ -69,9 +69,6 @@ public final class CacheWriter { * defined by the {@link DataSpec}. If {@code true} and the request exceeds the length of the * content, then the content will be cached to the end. If {@code false} and the request * exceeds the length of the content, {@link #cache} will throw an {@link IOException}. - * @param isCanceled An optional cancelation signal. If specified, {@link #cache} will check the - * value of this signal frequently during caching. If the value is {@code true}, the operation - * will be considered canceled and {@link #cache} will throw {@link InterruptedIOException}. * @param temporaryBuffer A temporary buffer to be used during caching, or {@code null} if the * writer should instantiate its own internal temporary buffer. * @param progressListener An optional progress listener. @@ -80,14 +77,12 @@ public final class CacheWriter { CacheDataSource dataSource, DataSpec dataSpec, boolean allowShortContent, - @Nullable AtomicBoolean isCanceled, @Nullable byte[] temporaryBuffer, @Nullable ProgressListener progressListener) { this.dataSource = dataSource; this.cache = dataSource.getCache(); this.dataSpec = dataSpec; this.allowShortContent = allowShortContent; - this.isCanceled = isCanceled == null ? new AtomicBoolean() : isCanceled; this.temporaryBuffer = temporaryBuffer == null ? new byte[DEFAULT_BUFFER_SIZE_BYTES] : temporaryBuffer; this.progressListener = progressListener; @@ -95,6 +90,15 @@ public final class CacheWriter { nextPosition = dataSpec.position; } + /** + * Cancels this writer's caching operation. {@link #cache} checks for cancelation frequently + * during execution, and throws an {@link InterruptedIOException} if it sees that the caching + * operation has been canceled. + */ + public void cancel() { + isCanceled = true; + } + /** * Caches the requested data, skipping any that's already cached. * @@ -230,7 +234,7 @@ public final class CacheWriter { } private void throwIfCanceled() throws InterruptedIOException { - if (isCanceled.get()) { + if (isCanceled) { throw new InterruptedIOException(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java b/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java new file mode 100644 index 0000000000..9f06f40a67 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java @@ -0,0 +1,172 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import androidx.annotation.Nullable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * A {@link RunnableFuture} that supports additional uninterruptible operations to query whether + * execution has started and finished. + * + * @param The type of the result. + * @param The type of any {@link ExecutionException} cause. + */ +public abstract class RunnableFutureTask implements RunnableFuture { + + private final ConditionVariable started; + private final ConditionVariable finished; + private final Object cancelLock; + + @Nullable private Exception exception; + @Nullable private R result; + + @Nullable private Thread workThread; + private boolean canceled; + + protected RunnableFutureTask() { + started = new ConditionVariable(); + finished = new ConditionVariable(); + cancelLock = new Object(); + } + + /** Blocks until the task has started, or has been canceled without having been started. */ + public final void blockUntilStarted() { + started.blockUninterruptible(); + } + + /** Blocks until the task has finished, or has been canceled without having been started. */ + public final void blockUntilFinished() { + finished.blockUninterruptible(); + } + + // Future implementation. + + @Override + @UnknownNull + public final R get() throws ExecutionException, InterruptedException { + finished.block(); + return getResult(); + } + + @Override + @UnknownNull + public final R get(long timeout, TimeUnit unit) + throws ExecutionException, InterruptedException, TimeoutException { + long timeoutMs = MILLISECONDS.convert(timeout, unit); + if (!finished.block(timeoutMs)) { + throw new TimeoutException(); + } + return getResult(); + } + + @Override + public final boolean cancel(boolean interruptIfRunning) { + synchronized (cancelLock) { + if (canceled || finished.isOpen()) { + return false; + } + canceled = true; + cancelWork(); + @Nullable Thread workThread = this.workThread; + if (workThread != null) { + if (interruptIfRunning) { + workThread.interrupt(); + } + } else { + started.open(); + finished.open(); + } + return true; + } + } + + @Override + public final boolean isDone() { + return finished.isOpen(); + } + + @Override + public final boolean isCancelled() { + return canceled; + } + + // Runnable implementation. + + @Override + public final void run() { + synchronized (cancelLock) { + if (canceled) { + return; + } + workThread = Thread.currentThread(); + } + started.open(); + try { + result = doWork(); + } catch (Exception e) { + // Must be an instance of E or RuntimeException. + exception = e; + } finally { + synchronized (cancelLock) { + finished.open(); + workThread = null; + // Clear the interrupted flag if set, to avoid it leaking into any subsequent tasks executed + // using the calling thread. + Thread.interrupted(); + } + } + } + + // Internal methods. + + /** + * Performs the work or computation. + * + * @return The computed result. + * @throws E If an error occurred. + */ + @UnknownNull + protected abstract R doWork() throws E; + + /** + * Cancels any work being done by {@link #doWork()}. If {@link #doWork()} is currently executing + * then the thread on which it's executing may be interrupted immediately after this method + * returns. + * + *

          The default implementation does nothing. + */ + protected void cancelWork() { + // Do nothing. + } + + @SuppressWarnings("return.type.incompatible") + @UnknownNull + private R getResult() throws ExecutionException { + if (canceled) { + throw new CancellationException(); + } else if (exception != null) { + throw new ExecutionException(exception); + } + return result; + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 328d80bf48..652a5643a7 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -362,7 +362,6 @@ public final class CacheDataSourceTest { new CacheDataSource(cache, upstream2), unboundedDataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, /* progressListener= */ null); cacheWriter.cache(); @@ -413,7 +412,6 @@ public final class CacheDataSourceTest { new CacheDataSource(cache, upstream2), unboundedDataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, /* progressListener= */ null); cacheWriter.cache(); @@ -439,7 +437,6 @@ public final class CacheDataSourceTest { new CacheDataSource(cache, upstream), dataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, /* progressListener= */ null); cacheWriter.cache(); @@ -477,7 +474,6 @@ public final class CacheDataSourceTest { new CacheDataSource(cache, upstream), dataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, /* progressListener= */ null); cacheWriter.cache(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java index 3e5cb119fd..d0cc42b062 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java @@ -116,7 +116,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), new DataSpec(Uri.parse("test_data")), /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -139,7 +138,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), dataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -152,7 +150,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), new DataSpec(testUri), /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -176,7 +173,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), dataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -201,7 +197,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), dataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -214,7 +209,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), new DataSpec(testUri), /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -237,7 +231,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), dataSpec, /* allowShortContent= */ true, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); @@ -262,7 +255,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), dataSpec, /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, /* progressListener= */ null) .cache()); @@ -288,7 +280,6 @@ public final class CacheWriterTest { new CacheDataSource(cache, dataSource), new DataSpec(Uri.parse("test_data")), /* allowShortContent= */ false, - /* isCanceled= */ null, /* temporaryBuffer= */ null, counters); cacheWriter.cache(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java new file mode 100644 index 0000000000..9a8aac3020 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link RunnableFutureTask}. */ +@RunWith(AndroidJUnit4.class) +public class RunnableFutureTaskTest { + + @Test + public void blockUntilStarted_ifNotStarted_blocks() throws InterruptedException { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + + AtomicBoolean blockUntilStartedReturned = new AtomicBoolean(); + Thread testThread = + new Thread() { + @Override + public void run() { + task.blockUntilStarted(); + blockUntilStartedReturned.set(true); + } + }; + testThread.start(); + + Thread.sleep(1000); + assertThat(blockUntilStartedReturned.get()).isFalse(); + + // Thread cleanup. + task.run(); + testThread.join(); + } + + @Test(timeout = 1000) + public void blockUntilStarted_ifStarted_unblocks() throws InterruptedException { + ConditionVariable finish = new ConditionVariable(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + finish.blockUninterruptible(); + return null; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + task.blockUntilStarted(); // Should unblock. + + // Thread cleanup. + finish.open(); + testThread.join(); + } + + @Test(timeout = 1000) + public void blockUntilStarted_ifCanceled_unblocks() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + + task.cancel(/* interruptIfRunning= */ false); + + // Should not block. + task.blockUntilStarted(); + } + + @Test + public void blockUntilFinished_ifNotFinished_blocks() throws InterruptedException { + ConditionVariable finish = new ConditionVariable(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + finish.blockUninterruptible(); + return null; + } + }; + Thread testThread1 = new Thread(task); + testThread1.start(); + + AtomicBoolean blockUntilFinishedReturned = new AtomicBoolean(); + Thread testThread2 = + new Thread() { + @Override + public void run() { + task.blockUntilFinished(); + blockUntilFinishedReturned.set(true); + } + }; + testThread2.start(); + + Thread.sleep(1000); + assertThat(blockUntilFinishedReturned.get()).isFalse(); + + // Thread cleanup. + finish.open(); + testThread1.join(); + testThread2.join(); + } + + @Test(timeout = 1000) + public void blockUntilFinished_ifFinished_unblocks() throws InterruptedException { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + task.blockUntilFinished(); + assertThat(task.isDone()).isTrue(); + + // Thread cleanup. + testThread.join(); + } + + @Test(timeout = 1000) + public void blockUntilFinished_ifCanceled_unblocks() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + + task.cancel(/* interruptIfRunning= */ false); + + // Should not block. + task.blockUntilFinished(); + } + + @Test + public void get_ifNotFinished_blocks() throws InterruptedException { + ConditionVariable finish = new ConditionVariable(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + finish.blockUninterruptible(); + return null; + } + }; + Thread testThread1 = new Thread(task); + testThread1.start(); + + AtomicBoolean blockUntilGetResultReturned = new AtomicBoolean(); + Thread testThread2 = + new Thread() { + @Override + public void run() { + try { + task.get(); + } catch (ExecutionException | InterruptedException e) { + // Do nothing. + } finally { + blockUntilGetResultReturned.set(true); + } + } + }; + testThread2.start(); + + Thread.sleep(1000); + assertThat(blockUntilGetResultReturned.get()).isFalse(); + + // Thread cleanup. + finish.open(); + testThread1.join(); + testThread2.join(); + } + + @Test(timeout = 1000) + public void get_returnsResult() throws ExecutionException, InterruptedException { + Object result = new Object(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() { + return result; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + assertThat(task.get()).isSameInstanceAs(result); + + // Thread cleanup. + testThread.join(); + } + + @Test(timeout = 1000) + public void get_throwsExecutionException_containsIOException() throws InterruptedException { + IOException exception = new IOException(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() throws IOException { + throw exception; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + ExecutionException executionException = assertThrows(ExecutionException.class, task::get); + assertThat(executionException).hasCauseThat().isSameInstanceAs(exception); + + // Thread cleanup. + testThread.join(); + } + + @Test(timeout = 1000) + public void get_throwsExecutionException_containsRuntimeException() throws InterruptedException { + RuntimeException exception = new RuntimeException(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() { + throw exception; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + ExecutionException executionException = assertThrows(ExecutionException.class, task::get); + assertThat(executionException).hasCauseThat().isSameInstanceAs(exception); + + // Thread cleanup. + testThread.join(); + } + + @Test + public void run_throwsError() { + Error error = new Error(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() { + throw error; + } + }; + Error thrownError = assertThrows(Error.class, task::run); + assertThat(thrownError).isSameInstanceAs(error); + } + + @Test + public void cancel_whenNotStarted_returnsTrue() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + assertThat(task.cancel(/* interruptIfRunning= */ false)).isTrue(); + } + + @Test + public void cancel_whenCanceled_returnsFalse() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + task.cancel(/* interruptIfRunning= */ false); + assertThat(task.cancel(/* interruptIfRunning= */ false)).isFalse(); + } +} diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java index 31a1f84674..7b99d55fd9 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java @@ -36,10 +36,12 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.RunnableFutureTask; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * A downloader for DASH streams. @@ -140,8 +142,8 @@ public final class DashDownloader extends SegmentDownloader { @Override protected List getSegments( - DataSource dataSource, DashManifest manifest, boolean allowIncompleteList) - throws IOException { + DataSource dataSource, DashManifest manifest, boolean removing) + throws IOException, InterruptedException { ArrayList segments = new ArrayList<>(); for (int i = 0; i < manifest.getPeriodCount(); i++) { Period period = manifest.getPeriod(i); @@ -150,36 +152,31 @@ public final class DashDownloader extends SegmentDownloader { List adaptationSets = period.adaptationSets; for (int j = 0; j < adaptationSets.size(); j++) { addSegmentsForAdaptationSet( - dataSource, - adaptationSets.get(j), - periodStartUs, - periodDurationUs, - allowIncompleteList, - segments); + dataSource, adaptationSets.get(j), periodStartUs, periodDurationUs, removing, segments); } } return segments; } - private static void addSegmentsForAdaptationSet( + private void addSegmentsForAdaptationSet( DataSource dataSource, AdaptationSet adaptationSet, long periodStartUs, long periodDurationUs, - boolean allowIncompleteList, + boolean removing, ArrayList out) - throws IOException { + throws IOException, InterruptedException { for (int i = 0; i < adaptationSet.representations.size(); i++) { Representation representation = adaptationSet.representations.get(i); DashSegmentIndex index; try { - index = getSegmentIndex(dataSource, adaptationSet.type, representation); + index = getSegmentIndex(dataSource, adaptationSet.type, representation, removing); if (index == null) { // Loading succeeded but there was no index. throw new DownloadException("Missing segment index"); } } catch (IOException e) { - if (!allowIncompleteList) { + if (!removing) { throw e; } // Generating an incomplete segment list is allowed. Advance to the next representation. @@ -215,16 +212,24 @@ public final class DashDownloader extends SegmentDownloader { out.add(new Segment(startTimeUs, dataSpec)); } - private static @Nullable DashSegmentIndex getSegmentIndex( - DataSource dataSource, int trackType, Representation representation) throws IOException { + @Nullable + private DashSegmentIndex getSegmentIndex( + DataSource dataSource, int trackType, Representation representation, boolean removing) + throws IOException, InterruptedException { DashSegmentIndex index = representation.getIndex(); if (index != null) { return index; } - ChunkIndex seekMap = DashUtil.loadChunkIndex(dataSource, trackType, representation); + RunnableFutureTask<@NullableType ChunkIndex, IOException> runnable = + new RunnableFutureTask<@NullableType ChunkIndex, IOException>() { + @Override + protected @NullableType ChunkIndex doWork() throws IOException { + return DashUtil.loadChunkIndex(dataSource, trackType, representation); + } + }; + @Nullable ChunkIndex seekMap = execute(runnable, removing); return seekMap == null ? null : new DashWrappingSegmentIndex(seekMap, representation.presentationTimeOffsetUs); } - } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java index 858fe8f527..39462f3d06 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java @@ -134,8 +134,8 @@ public final class HlsDownloader extends SegmentDownloader { } @Override - protected List getSegments( - DataSource dataSource, HlsPlaylist playlist, boolean allowIncompleteList) throws IOException { + protected List getSegments(DataSource dataSource, HlsPlaylist playlist, boolean removing) + throws IOException, InterruptedException { ArrayList mediaPlaylistDataSpecs = new ArrayList<>(); if (playlist instanceof HlsMasterPlaylist) { HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; @@ -151,9 +151,9 @@ public final class HlsDownloader extends SegmentDownloader { segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec)); HlsMediaPlaylist mediaPlaylist; try { - mediaPlaylist = (HlsMediaPlaylist) getManifest(dataSource, mediaPlaylistDataSpec); + mediaPlaylist = (HlsMediaPlaylist) getManifest(dataSource, mediaPlaylistDataSpec, removing); } catch (IOException e) { - if (!allowIncompleteList) { + if (!removing) { throw e; } // Generating an incomplete segment list is allowed. Advance to the next media playlist. From 8e09cf45c0922579c994910d40780375ba2cce03 Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 29 Jun 2020 08:22:40 +0100 Subject: [PATCH 0556/1052] Fix bug unseekable FMP4 The seek start position was set to the first mdat but this box was always skipped because the moof box was not read. PiperOrigin-RevId: 318762126 --- .../extractor/mp4/FragmentedMp4Extractor.java | 10 +++---- .../assets/mp4/sample_fragmented.mp4.0.dump | 2 +- .../sample_fragmented.mp4.unknown_length.dump | 2 +- .../mp4/sample_fragmented_sei.mp4.0.dump | 2 +- ...ple_fragmented_sei.mp4.unknown_length.dump | 2 +- .../exoplayer2/testutil/ExtractorAsserts.java | 30 +++++++++++-------- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 37df66ba2c..cf84eab20d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -374,16 +374,16 @@ public class FragmentedMp4Extractor implements Extractor { fragment.auxiliaryDataPosition = atomPosition; fragment.dataPosition = atomPosition; } + if (!haveOutputSeekMap) { + // This must be the first moof in the stream. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs, atomPosition)); + haveOutputSeekMap = true; + } } if (atomType == Atom.TYPE_mdat) { currentTrackBundle = null; endOfMdatPosition = atomPosition + atomSize; - if (!haveOutputSeekMap) { - // This must be the first mdat in the stream. - extractorOutput.seekMap(new SeekMap.Unseekable(durationUs, atomPosition)); - haveOutputSeekMap = true; - } parserState = STATE_READING_ENCRYPTION_DATA; return true; } diff --git a/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump b/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump index 2a5848f5a4..001895e61e 100644 --- a/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump +++ b/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = false duration = UNSET TIME - getPosition(0) = [[timeUs=0, position=1828]] + getPosition(0) = [[timeUs=0, position=1244]] numberOfTracks = 2 track 0: total output bytes = 85933 diff --git a/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump b/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump index 2a5848f5a4..001895e61e 100644 --- a/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump +++ b/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = false duration = UNSET TIME - getPosition(0) = [[timeUs=0, position=1828]] + getPosition(0) = [[timeUs=0, position=1244]] numberOfTracks = 2 track 0: total output bytes = 85933 diff --git a/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump b/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump index 341fba46b9..f88092002b 100644 --- a/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump +++ b/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = false duration = UNSET TIME - getPosition(0) = [[timeUs=0, position=1828]] + getPosition(0) = [[timeUs=0, position=1244]] numberOfTracks = 3 track 0: total output bytes = 85933 diff --git a/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump b/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump index 341fba46b9..f88092002b 100644 --- a/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump +++ b/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = false duration = UNSET TIME - getPosition(0) = [[timeUs=0, position=1828]] + getPosition(0) = [[timeUs=0, position=1244]] numberOfTracks = 3 track 0: total output bytes = 85933 diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java index da585ee4f0..7411016177 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java @@ -341,21 +341,25 @@ public final class ExtractorAsserts { extractorOutput.assertOutput(context, dumpFilesPrefix + ".0" + DUMP_EXTENSION); } - // If the SeekMap is seekable, test seeking in the stream. SeekMap seekMap = Assertions.checkNotNull(extractorOutput.seekMap); - if (seekMap.isSeekable()) { - long durationUs = seekMap.getDurationUs(); - for (int j = 0; j < 4; j++) { - extractorOutput.clearTrackOutputs(); - long timeUs = durationUs == C.TIME_UNSET ? 0 : (durationUs * j) / 3; - long position = seekMap.getSeekPoints(timeUs).first.position; - input.reset(); - input.setPosition((int) position); - consumeTestData(extractor, input, timeUs, extractorOutput, false); + long durationUs = seekMap.getDurationUs(); + // Only seek to the timeUs=0 if the SeekMap is unseekable or the duration is unknown. + int numberSeekTests = seekMap.isSeekable() && durationUs != C.TIME_UNSET ? 4 : 1; + for (int j = 0; j < numberSeekTests; j++) { + long timeUs = durationUs * j / 3; + long position = seekMap.getSeekPoints(timeUs).first.position; + if (timeUs == 0 && position == 0) { + // Already tested. + continue; + } + input.reset(); + input.setPosition((int) position); + extractorOutput.clearTrackOutputs(); + consumeTestData(extractor, input, timeUs, extractorOutput, false); + if (simulateUnknownLength && timeUs == 0) { + extractorOutput.assertOutput(context, dumpFilesPrefix + UNKNOWN_LENGTH_EXTENSION); + } else { extractorOutput.assertOutput(context, dumpFilesPrefix + '.' + j + DUMP_EXTENSION); - if (durationUs == C.TIME_UNSET) { - break; - } } } } From 6884dfb313e5cb718a6f87ca4bcb25bf65609865 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 29 Jun 2020 11:58:13 +0100 Subject: [PATCH 0557/1052] Move SimpleExoPlayer.Builder unit test to a separate class PiperOrigin-RevId: 318785458 --- .../android/exoplayer2/ExoPlayerTest.java | 15 ------ .../exoplayer2/SimpleExoPlayerTest.java | 46 +++++++++++++++++++ 2 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 6397df6716..30af89dd08 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -105,7 +105,6 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.Config; import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowAudioManager; @@ -6767,20 +6766,6 @@ public final class ExoPlayerTest { assertThat(initialMediaItems).containsExactlyElementsIn(currentMediaItems); } - // TODO: Revert to @Config(sdk = Config.ALL_SDKS) once b/143232359 is resolved - @Test - @Config(minSdk = Config.OLDEST_SDK, maxSdk = Config.TARGET_SDK) - public void buildSimpleExoPlayerInBackgroundThread_doesNotThrow() throws Exception { - Thread builderThread = new Thread(() -> new SimpleExoPlayer.Builder(context).build()); - AtomicReference builderThrow = new AtomicReference<>(); - builderThread.setUncaughtExceptionHandler((thread, throwable) -> builderThrow.set(throwable)); - - builderThread.start(); - builderThread.join(); - - assertThat(builderThrow.get()).isNull(); - } - // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java new file mode 100644 index 0000000000..e3a625a3ce --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** Unit test for {@link SimpleExoPlayer}. */ +@RunWith(AndroidJUnit4.class) +public class SimpleExoPlayerTest { + + // TODO(b/143232359): Revert to @Config(sdk = Config.ALL_SDKS) once b/143232359 is resolved + @Test + @Config(minSdk = Config.OLDEST_SDK, maxSdk = Config.TARGET_SDK) + public void builder_inBackgroundThread_doesNotThrow() throws Exception { + Thread builderThread = + new Thread( + () -> new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()).build()); + AtomicReference builderThrow = new AtomicReference<>(); + builderThread.setUncaughtExceptionHandler((thread, throwable) -> builderThrow.set(throwable)); + + builderThread.start(); + builderThread.join(); + + assertThat(builderThrow.get()).isNull(); + } +} From f770ff677f9f110c0c071c37c05b22c73ea69ba8 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 29 Jun 2020 12:06:19 +0100 Subject: [PATCH 0558/1052] Fix method Javadoc. PiperOrigin-RevId: 318786283 --- .../google/android/exoplayer2/source/MaskingMediaSource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index ee88725193..01ad02017d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -275,9 +275,9 @@ public final class MaskingMediaSource extends CompositeMediaSource { @Nullable private final Object replacedInternalPeriodUid; /** - * Returns an instance with a dummy timeline using the provided window tag. + * Returns an instance with a dummy timeline using the provided {@link MediaItem}. * - * @param windowTag A window tag. + * @param mediaItem A {@link MediaItem}. */ public static MaskingTimeline createWithDummyTimeline(MediaItem mediaItem) { return new MaskingTimeline( From 78825a41dc7d1de5c1a0cf57448c4b90646234ed Mon Sep 17 00:00:00 2001 From: krocard Date: Mon, 29 Jun 2020 12:36:19 +0100 Subject: [PATCH 0559/1052] Store encodings in Format instead of just pcm encodings Previously only pcm encoding were stored in Format, this was an issue as for audio passthrough and offload lots of code needs to pass complex format informations (encoding, sample rate, channel count, gapless metadata) but could not use Format and each function was taking each as different parameter. By allowing Format to contain any encoding, and not only pcmEncoding, it allows to pass a Format everywhere in ExoPlayer code that needs a Format. This patch does not have any functional change. It is only an internal refactor. PiperOrigin-RevId: 318789444 --- .../ext/ffmpeg/FfmpegAudioRenderer.java | 14 ++-- .../exoplayer2/ext/flac/FlacExtractor.java | 2 +- .../ext/flac/LibflacAudioRenderer.java | 4 +- .../ext/opus/LibopusAudioRenderer.java | 4 +- .../com/google/android/exoplayer2/Format.java | 51 +++++++----- .../android/exoplayer2/audio/AudioSink.java | 37 +++------ .../audio/ChannelMappingAudioProcessor.java | 2 +- .../audio/DecoderAudioRenderer.java | 16 ++-- .../exoplayer2/audio/DefaultAudioSink.java | 79 ++++++++---------- .../exoplayer2/audio/ForwardingAudioSink.java | 23 +----- .../audio/MediaCodecAudioRenderer.java | 82 ++++++++++--------- .../audio/TrimmingAudioProcessor.java | 2 +- .../exoplayer2/source/SilenceMediaSource.java | 2 +- .../audio/DefaultAudioSinkTest.java | 26 +++--- .../audio/MediaCodecAudioRendererTest.java | 38 ++------- .../mediacodec/C2Mp3TimestampTrackerTest.java | 2 +- .../extractor/mkv/MatroskaExtractor.java | 2 +- .../exoplayer2/extractor/mp4/AtomParsers.java | 38 ++++----- .../extractor/wav/WavExtractor.java | 4 +- .../bear_no_min_max_frame_size_raw.0.dump | 2 +- .../bear_no_min_max_frame_size_raw.1.dump | 2 +- .../bear_no_min_max_frame_size_raw.2.dump | 2 +- .../bear_no_min_max_frame_size_raw.3.dump | 2 +- ...min_max_frame_size_raw.unknown_length.dump | 2 +- .../flac/bear_no_num_samples_raw.0.dump | 2 +- ...ear_no_num_samples_raw.unknown_length.dump | 2 +- ...ar_no_seek_table_no_num_samples_raw.0.dump | 2 +- ...ble_no_num_samples_raw.unknown_length.dump | 2 +- .../flac/bear_one_metadata_block_raw.0.dump | 2 +- .../flac/bear_one_metadata_block_raw.1.dump | 2 +- .../flac/bear_one_metadata_block_raw.2.dump | 2 +- .../flac/bear_one_metadata_block_raw.3.dump | 2 +- ...one_metadata_block_raw.unknown_length.dump | 2 +- testdata/src/test/assets/flac/bear_raw.0.dump | 2 +- testdata/src/test/assets/flac/bear_raw.1.dump | 2 +- testdata/src/test/assets/flac/bear_raw.2.dump | 2 +- testdata/src/test/assets/flac/bear_raw.3.dump | 2 +- .../assets/flac/bear_raw.unknown_length.dump | 2 +- .../flac/bear_uncommon_sample_rate_raw.0.dump | 2 +- .../flac/bear_uncommon_sample_rate_raw.1.dump | 2 +- .../flac/bear_uncommon_sample_rate_raw.2.dump | 2 +- .../flac/bear_uncommon_sample_rate_raw.3.dump | 2 +- ...common_sample_rate_raw.unknown_length.dump | 2 +- .../flac/bear_with_id3_disabled_raw.0.dump | 2 +- .../flac/bear_with_id3_disabled_raw.1.dump | 2 +- .../flac/bear_with_id3_disabled_raw.2.dump | 2 +- .../flac/bear_with_id3_disabled_raw.3.dump | 2 +- ..._with_id3_disabled_raw.unknown_length.dump | 2 +- .../flac/bear_with_id3_enabled_raw.0.dump | 2 +- .../flac/bear_with_id3_enabled_raw.1.dump | 2 +- .../flac/bear_with_id3_enabled_raw.2.dump | 2 +- .../flac/bear_with_id3_enabled_raw.3.dump | 2 +- ...r_with_id3_enabled_raw.unknown_length.dump | 2 +- .../assets/flac/bear_with_picture_raw.0.dump | 2 +- .../assets/flac/bear_with_picture_raw.1.dump | 2 +- .../assets/flac/bear_with_picture_raw.2.dump | 2 +- .../assets/flac/bear_with_picture_raw.3.dump | 2 +- .../bear_with_picture_raw.unknown_length.dump | 2 +- .../flac/bear_with_vorbis_comments_raw.0.dump | 2 +- .../flac/bear_with_vorbis_comments_raw.1.dump | 2 +- .../flac/bear_with_vorbis_comments_raw.2.dump | 2 +- .../flac/bear_with_vorbis_comments_raw.3.dump | 2 +- ...th_vorbis_comments_raw.unknown_length.dump | 2 +- .../src/test/assets/wav/sample.wav.0.dump | 2 +- .../src/test/assets/wav/sample.wav.1.dump | 2 +- .../src/test/assets/wav/sample.wav.2.dump | 2 +- .../src/test/assets/wav/sample.wav.3.dump | 2 +- .../assets/wav/sample.wav.unknown_length.dump | 2 +- .../assets/wav/sample_ima_adpcm.wav.0.dump | 2 +- .../assets/wav/sample_ima_adpcm.wav.1.dump | 2 +- .../assets/wav/sample_ima_adpcm.wav.2.dump | 2 +- .../assets/wav/sample_ima_adpcm.wav.3.dump | 2 +- .../sample_ima_adpcm.wav.unknown_length.dump | 2 +- .../testutil/CapturingAudioSink.java | 22 ++--- .../exoplayer2/testutil/FakeTrackOutput.java | 2 +- 75 files changed, 250 insertions(+), 310 deletions(-) diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index 7d53c519a7..dcc694736b 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -137,25 +137,27 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { .setSampleMimeType(MimeTypes.AUDIO_RAW) .setChannelCount(decoder.getChannelCount()) .setSampleRate(decoder.getSampleRate()) - .setPcmEncoding(decoder.getEncoding()) + .setEncoding(decoder.getEncoding()) .build(); } private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) || supportsOutput(inputFormat, C.ENCODING_PCM_16BIT); + return shouldUseFloatOutput(inputFormat) + || supportsOutput(inputFormat.buildUpon().setEncoding(C.ENCODING_PCM_16BIT).build()); } private boolean shouldUseFloatOutput(Format inputFormat) { Assertions.checkNotNull(inputFormat.sampleMimeType); - if (!enableFloatOutput || !supportsOutput(inputFormat, C.ENCODING_PCM_FLOAT)) { + if (!enableFloatOutput + || !supportsOutput(inputFormat.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build())) { return false; } switch (inputFormat.sampleMimeType) { case MimeTypes.AUDIO_RAW: // For raw audio, output in 32-bit float encoding if the bit depth is > 16-bit. - return inputFormat.pcmEncoding == C.ENCODING_PCM_24BIT - || inputFormat.pcmEncoding == C.ENCODING_PCM_32BIT - || inputFormat.pcmEncoding == C.ENCODING_PCM_FLOAT; + return inputFormat.encoding == C.ENCODING_PCM_24BIT + || inputFormat.encoding == C.ENCODING_PCM_32BIT + || inputFormat.encoding == C.ENCODING_PCM_FLOAT; case MimeTypes.AUDIO_AC3: // AC-3 is always 16-bit, so there is no point outputting in 32-bit float encoding. return false; diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 615b60c3e7..1d6ba75ce8 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -279,7 +279,7 @@ public final class FlacExtractor implements Extractor { .setMaxInputSize(streamMetadata.getMaxDecodedFrameSize()) .setChannelCount(streamMetadata.channels) .setSampleRate(streamMetadata.sampleRate) - .setPcmEncoding(getPcmEncoding(streamMetadata.bitsPerSample)) + .setEncoding(getPcmEncoding(streamMetadata.bitsPerSample)) .setMetadata(metadata) .build(); output.format(mediaFormat); diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 24a247fc76..c31094ff63 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -100,7 +100,7 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset); pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample); } - if (!supportsOutput(format, pcmEncoding)) { + if (!supportsOutput(format.buildUpon().setEncoding(pcmEncoding).build())) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { return FORMAT_UNSUPPORTED_DRM; @@ -127,7 +127,7 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { .setSampleMimeType(MimeTypes.AUDIO_RAW) .setChannelCount(streamMetadata.channels) .setSampleRate(streamMetadata.sampleRate) - .setPcmEncoding(Util.getPcmEncoding(streamMetadata.bitsPerSample)) + .setEncoding(Util.getPcmEncoding(streamMetadata.bitsPerSample)) .build(); } } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index cafa337cf2..25b52ec292 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -69,7 +69,7 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { if (!OpusLibrary.isAvailable() || !MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!supportsOutput(format, C.ENCODING_PCM_16BIT)) { + } else if (!supportsOutput(format.buildUpon().setEncoding(C.ENCODING_PCM_16BIT).build())) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (!drmIsSupported) { return FORMAT_UNSUPPORTED_DRM; @@ -103,7 +103,7 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { .setSampleMimeType(MimeTypes.AUDIO_RAW) .setChannelCount(channelCount) .setSampleRate(sampleRate) - .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setEncoding(C.ENCODING_PCM_16BIT) .build(); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Format.java b/library/common/src/main/java/com/google/android/exoplayer2/Format.java index e7db47d535..6ff41d0c72 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Format.java @@ -94,7 +94,7 @@ import java.util.List; *

            *
          • {@link #channelCount} *
          • {@link #sampleRate} - *
          • {@link #pcmEncoding} + *
          • {@link #encoding} *
          • {@link #encoderDelay} *
          • {@link #encoderPadding} *
          @@ -155,7 +155,7 @@ public final class Format implements Parcelable { private int channelCount; private int sampleRate; - @C.PcmEncoding private int pcmEncoding; + @C.Encoding private int encoding; private int encoderDelay; private int encoderPadding; @@ -183,7 +183,7 @@ public final class Format implements Parcelable { // Audio specific. channelCount = NO_VALUE; sampleRate = NO_VALUE; - pcmEncoding = NO_VALUE; + encoding = NO_VALUE; // Text specific. accessibilityChannel = NO_VALUE; } @@ -223,7 +223,7 @@ public final class Format implements Parcelable { // Audio specific. this.channelCount = format.channelCount; this.sampleRate = format.sampleRate; - this.pcmEncoding = format.pcmEncoding; + this.encoding = format.encoding; this.encoderDelay = format.encoderDelay; this.encoderPadding = format.encoderPadding; // Text specific. @@ -528,13 +528,13 @@ public final class Format implements Parcelable { } /** - * Sets {@link Format#pcmEncoding}. The default value is {@link #NO_VALUE}. + * Sets {@link Format#encoding}. The default value is {@link #NO_VALUE}. * - * @param pcmEncoding The {@link Format#pcmEncoding}. + * @param encoding The {@link Format#encoding}. * @return The builder. */ - public Builder setPcmEncoding(@C.PcmEncoding int pcmEncoding) { - this.pcmEncoding = pcmEncoding; + public Builder setEncoding(@C.Encoding int encoding) { + this.encoding = encoding; return this; } @@ -616,7 +616,7 @@ public final class Format implements Parcelable { colorInfo, channelCount, sampleRate, - pcmEncoding, + encoding, encoderDelay, encoderPadding, accessibilityChannel, @@ -765,8 +765,10 @@ public final class Format implements Parcelable { * The audio sampling rate in Hz, or {@link #NO_VALUE} if unknown or not applicable. */ public final int sampleRate; - /** The {@link C.PcmEncoding} for PCM audio. Set to {@link #NO_VALUE} for other media types. */ - @C.PcmEncoding public final int pcmEncoding; + /** @deprecated Use {@link #encoding}. */ + @Deprecated @C.PcmEncoding public final int pcmEncoding; + /** The {@link C.Encoding} for audio. Set to {@link #NO_VALUE} for other media types. */ + @C.Encoding public final int encoding; /** * The number of frames to trim from the start of the decoded audio stream, or 0 if not * applicable. @@ -1004,7 +1006,7 @@ public final class Format implements Parcelable { int maxInputSize, int channelCount, int sampleRate, - @C.PcmEncoding int pcmEncoding, + @C.Encoding int encoding, @Nullable List initializationData, @Nullable DrmInitData drmInitData, @C.SelectionFlags int selectionFlags, @@ -1022,7 +1024,7 @@ public final class Format implements Parcelable { .setDrmInitData(drmInitData) .setChannelCount(channelCount) .setSampleRate(sampleRate) - .setPcmEncoding(pcmEncoding) + .setEncoding(encoding) .build(); } @@ -1036,7 +1038,7 @@ public final class Format implements Parcelable { int maxInputSize, int channelCount, int sampleRate, - @C.PcmEncoding int pcmEncoding, + @C.Encoding int encoding, int encoderDelay, int encoderPadding, @Nullable List initializationData, @@ -1058,7 +1060,7 @@ public final class Format implements Parcelable { .setDrmInitData(drmInitData) .setChannelCount(channelCount) .setSampleRate(sampleRate) - .setPcmEncoding(pcmEncoding) + .setEncoding(encoding) .setEncoderDelay(encoderDelay) .setEncoderPadding(encoderPadding) .build(); @@ -1239,7 +1241,7 @@ public final class Format implements Parcelable { // Audio specific. int channelCount, int sampleRate, - @C.PcmEncoding int pcmEncoding, + @C.Encoding int encoding, int encoderDelay, int encoderPadding, // Text specific. @@ -1277,7 +1279,8 @@ public final class Format implements Parcelable { // Audio specific. this.channelCount = channelCount; this.sampleRate = sampleRate; - this.pcmEncoding = pcmEncoding; + this.encoding = encoding; + this.pcmEncoding = toPcmEncoding(encoding); this.encoderDelay = encoderDelay == NO_VALUE ? 0 : encoderDelay; this.encoderPadding = encoderPadding == NO_VALUE ? 0 : encoderPadding; // Text specific. @@ -1323,7 +1326,8 @@ public final class Format implements Parcelable { // Audio specific. channelCount = in.readInt(); sampleRate = in.readInt(); - pcmEncoding = in.readInt(); + encoding = in.readInt(); + pcmEncoding = toPcmEncoding(encoding); encoderDelay = in.readInt(); encoderPadding = in.readInt(); // Text specific. @@ -1551,7 +1555,7 @@ public final class Format implements Parcelable { // Audio specific. result = 31 * result + channelCount; result = 31 * result + sampleRate; - result = 31 * result + pcmEncoding; + result = 31 * result + encoding; result = 31 * result + encoderDelay; result = 31 * result + encoderPadding; // Text specific. @@ -1588,7 +1592,7 @@ public final class Format implements Parcelable { && stereoMode == other.stereoMode && channelCount == other.channelCount && sampleRate == other.sampleRate - && pcmEncoding == other.pcmEncoding + && encoding == other.encoding && encoderDelay == other.encoderDelay && encoderPadding == other.encoderPadding && accessibilityChannel == other.accessibilityChannel @@ -1709,7 +1713,7 @@ public final class Format implements Parcelable { // Audio specific. dest.writeInt(channelCount); dest.writeInt(sampleRate); - dest.writeInt(pcmEncoding); + dest.writeInt(encoding); dest.writeInt(encoderDelay); dest.writeInt(encoderPadding); // Text specific. @@ -1729,4 +1733,9 @@ public final class Format implements Parcelable { } }; + + @C.PcmEncoding + private static int toPcmEncoding(@C.Encoding int encoding) { + return Util.isEncodingLinearPcm(encoding) ? encoding : NO_VALUE; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 8d1fa0cc4f..1fea32bdf0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.audio; import android.media.AudioTrack; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.C.Encoding; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -26,16 +25,15 @@ import java.nio.ByteBuffer; /** * A sink that consumes audio data. * - *

          Before starting playback, specify the input audio format by calling {@link #configure(int, - * int, int, int, int[], int, int)}. + *

          Before starting playback, specify the input audio format by calling {@link #configure(Format, + * int, int[])}. * *

          Call {@link #handleBuffer(ByteBuffer, long, int)} to write data, and {@link * #handleDiscontinuity()} when the data being fed is discontinuous. Call {@link #play()} to start * playing the written data. * - *

          Call {@link #configure(int, int, int, int, int[], int, int)} whenever the input format - * changes. The sink will be reinitialized on the next call to {@link #handleBuffer(ByteBuffer, - * long, int)}. + *

          Call {@link #configure(Format, int, int[])} whenever the input format changes. The sink will + * be reinitialized on the next call to {@link #handleBuffer(ByteBuffer, long, int)}. * *

          Call {@link #flush()} to prepare the sink to receive audio data from a new playback position. * @@ -188,12 +186,10 @@ public interface AudioSink { /** * Returns whether the sink supports the audio format. * - * @param format The format of the audio. {@link Format#pcmEncoding} is ignored and the {@code - * encoding} argument is used instead. - * @param encoding The audio encoding, or {@link Format#NO_VALUE} if not known. + * @param format The format of the audio. * @return Whether the sink supports the audio format. */ - boolean supportsOutput(Format format, @Encoding int encoding); + boolean supportsOutput(Format format); /** * Returns the playback position in the stream starting at zero, in microseconds, or @@ -207,9 +203,7 @@ public interface AudioSink { /** * Configures (or reconfigures) the sink. * - * @param inputEncoding The encoding of audio data provided in the input buffers. - * @param inputChannelCount The number of channels. - * @param inputSampleRate The sample rate in Hz. + * @param inputFormat The format of audio data provided in the input buffers. * @param specifiedBufferSize A specific size for the playback buffer in bytes, or 0 to infer a * suitable buffer size. * @param outputChannels A mapping from input to output channels that is applied to this sink's @@ -217,20 +211,9 @@ public interface AudioSink { * input unchanged. Otherwise, the element at index {@code i} specifies index of the input * channel to map to output channel {@code i} when preprocessing input buffers. After the map * is applied the audio data will have {@code outputChannels.length} channels. - * @param trimStartFrames The number of audio frames to trim from the start of data written to the - * sink after this call. - * @param trimEndFrames The number of audio frames to trim from data written to the sink - * immediately preceding the next call to {@link #flush()} or this method. * @throws ConfigurationException If an error occurs configuring the sink. */ - void configure( - @C.Encoding int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException; /** @@ -249,8 +232,8 @@ public interface AudioSink { * *

          Returns whether the data was handled in full. If the data was not handled in full then the * same {@link ByteBuffer} must be provided to subsequent calls until it has been fully consumed, - * except in the case of an intervening call to {@link #flush()} (or to {@link #configure(int, - * int, int, int, int[], int, int)} that causes the sink to be flushed). + * except in the case of an intervening call to {@link #flush()} (or to {@link #configure(Format, + * int, int[])} that causes the sink to be flushed). * * @param buffer The buffer containing audio data. * @param presentationTimeUs The presentation timestamp of the buffer in microseconds. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java index b94d972dc5..c064c7e459 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java @@ -35,7 +35,7 @@ import java.nio.ByteBuffer; * * @param outputChannels The mapping from input to output channel indices, or {@code null} to * leave the input unchanged. - * @see AudioSink#configure(int, int, int, int, int[], int, int) + * @see AudioSink#configure(com.google.android.exoplayer2.Format, int, int[]) */ public void setChannelMap(@Nullable int[] outputChannels) { pendingOutputChannels = outputChannels; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index b2eb9d9f50..e60bc31d01 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -213,10 +213,10 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media /** * Returns whether the sink supports the audio format. * - * @see AudioSink#supportsOutput(Format, int) + * @see AudioSink#supportsOutput(Format) */ - protected final boolean supportsOutput(Format format, @C.Encoding int encoding) { - return audioSink.supportsOutput(format, encoding); + protected final boolean supportsOutput(Format format) { + return audioSink.supportsOutput(format); } @Override @@ -358,9 +358,13 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media } if (audioTrackNeedsConfigure) { - Format outputFormat = getOutputFormat(); - audioSink.configure(outputFormat.pcmEncoding, outputFormat.channelCount, - outputFormat.sampleRate, 0, null, encoderDelay, encoderPadding); + Format outputFormat = + getOutputFormat() + .buildUpon() + .setEncoderDelay(encoderDelay) + .setEncoderPadding(encoderPadding) + .build(); + audioSink.configure(outputFormat, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); audioTrackNeedsConfigure = false; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 4c16d747ad..3d06d3b154 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -419,28 +419,28 @@ public final class DefaultAudioSink implements AudioSink { } @Override - public boolean supportsOutput(Format format, @C.Encoding int encoding) { - if (encoding == C.ENCODING_INVALID) { + public boolean supportsOutput(Format format) { + if (format.encoding == C.ENCODING_INVALID) { return false; } - if (Util.isEncodingLinearPcm(encoding)) { + if (Util.isEncodingLinearPcm(format.encoding)) { // AudioTrack supports 16-bit integer PCM output in all platform API versions, and float // output from platform API version 21 only. Other integer PCM encodings are resampled by this // sink to 16-bit PCM. We assume that the audio framework will downsample any number of // channels to the output device's required number of channels. - return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; + return format.encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; } if (enableOffload && isOffloadedPlaybackSupported( format.channelCount, format.sampleRate, - encoding, + format.encoding, audioAttributes, format.encoderDelay, format.encoderPadding)) { return true; } - return isPassthroughPlaybackSupported(encoding, format.channelCount); + return isPassthroughPlaybackSupported(format); } @Override @@ -454,16 +454,9 @@ public final class DefaultAudioSink implements AudioSink { } @Override - public void configure( - @C.Encoding int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { - if (Util.SDK_INT < 21 && inputChannelCount == 8 && outputChannels == null) { + if (Util.SDK_INT < 21 && inputFormat.channelCount == 8 && outputChannels == null) { // AudioTrack doesn't support 8 channel output before Android L. Discard the last two (side) // channels to give a 6 channel stream that is supported. outputChannels = new int[6]; @@ -472,24 +465,20 @@ public final class DefaultAudioSink implements AudioSink { } } - boolean isInputPcm = Util.isEncodingLinearPcm(inputEncoding); + boolean isInputPcm = Util.isEncodingLinearPcm(inputFormat.encoding); boolean processingEnabled = isInputPcm; - int sampleRate = inputSampleRate; - int channelCount = inputChannelCount; - @C.Encoding int encoding = inputEncoding; + int sampleRate = inputFormat.sampleRate; + int channelCount = inputFormat.channelCount; + @C.Encoding int encoding = inputFormat.encoding; boolean useFloatOutput = enableFloatOutput - && Util.isEncodingHighResolutionPcm(inputEncoding) - && supportsOutput( - new Format.Builder() - .setChannelCount(inputChannelCount) - .setSampleRate(inputSampleRate) - .build(), - C.ENCODING_PCM_FLOAT); + && Util.isEncodingHighResolutionPcm(inputFormat.encoding) + && supportsOutput(inputFormat.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build()); AudioProcessor[] availableAudioProcessors = useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; if (processingEnabled) { - trimmingAudioProcessor.setTrimFrameCount(trimStartFrames, trimEndFrames); + trimmingAudioProcessor.setTrimFrameCount( + inputFormat.encoderDelay, inputFormat.encoderPadding); channelMappingAudioProcessor.setChannelMap(outputChannels); AudioProcessor.AudioFormat outputFormat = new AudioProcessor.AudioFormat(sampleRate, channelCount, encoding); @@ -514,7 +503,9 @@ public final class DefaultAudioSink implements AudioSink { } int inputPcmFrameSize = - isInputPcm ? Util.getPcmFrameSize(inputEncoding, inputChannelCount) : C.LENGTH_UNSET; + isInputPcm + ? Util.getPcmFrameSize(inputFormat.encoding, inputFormat.channelCount) + : C.LENGTH_UNSET; int outputPcmFrameSize = isInputPcm ? Util.getPcmFrameSize(encoding, channelCount) : C.LENGTH_UNSET; boolean canApplyPlaybackParameters = processingEnabled && !useFloatOutput; @@ -526,14 +517,14 @@ public final class DefaultAudioSink implements AudioSink { sampleRate, encoding, audioAttributes, - trimStartFrames, - trimEndFrames); + inputFormat.encoderDelay, + inputFormat.encoderPadding); Configuration pendingConfiguration = new Configuration( isInputPcm, inputPcmFrameSize, - inputSampleRate, + inputFormat.sampleRate, outputPcmFrameSize, sampleRate, outputChannelConfig, @@ -542,8 +533,8 @@ public final class DefaultAudioSink implements AudioSink { processingEnabled, canApplyPlaybackParameters, availableAudioProcessors, - trimStartFrames, - trimEndFrames, + inputFormat.encoderDelay, + inputFormat.encoderPadding, useOffload); if (isInitialized()) { this.pendingConfiguration = pendingConfiguration; @@ -1253,21 +1244,21 @@ public final class DefaultAudioSink implements AudioSink { : writtenEncodedFrames; } - private boolean isPassthroughPlaybackSupported(@C.Encoding int encoding, int channelCount) { + private boolean isPassthroughPlaybackSupported(Format format) { // Check for encodings that are known to work for passthrough with the implementation in this // class. This avoids trying to use passthrough with an encoding where the device/app reports // it's capable but it is untested or known to be broken (for example AAC-LC). return audioCapabilities != null - && audioCapabilities.supportsEncoding(encoding) - && (encoding == C.ENCODING_AC3 - || encoding == C.ENCODING_E_AC3 - || encoding == C.ENCODING_E_AC3_JOC - || encoding == C.ENCODING_AC4 - || encoding == C.ENCODING_DTS - || encoding == C.ENCODING_DTS_HD - || encoding == C.ENCODING_DOLBY_TRUEHD) - && (channelCount == Format.NO_VALUE - || channelCount <= audioCapabilities.getMaxChannelCount()); + && audioCapabilities.supportsEncoding(format.encoding) + && (format.encoding == C.ENCODING_AC3 + || format.encoding == C.ENCODING_E_AC3 + || format.encoding == C.ENCODING_E_AC3_JOC + || format.encoding == C.ENCODING_AC4 + || format.encoding == C.ENCODING_DTS + || format.encoding == C.ENCODING_DTS_HD + || format.encoding == C.ENCODING_DOLBY_TRUEHD) + && (format.channelCount == Format.NO_VALUE + || format.channelCount <= audioCapabilities.getMaxChannelCount()); } private static boolean isOffloadedPlaybackSupported( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java index f01b55a3f1..fea7094931 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C.Encoding; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -36,8 +35,8 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public boolean supportsOutput(Format format, @Encoding int encoding) { - return sink.supportsOutput(format, encoding); + public boolean supportsOutput(Format format) { + return sink.supportsOutput(format); } @Override @@ -46,23 +45,9 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public void configure( - int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { - sink.configure( - inputEncoding, - inputChannelCount, - inputSampleRate, - specifiedBufferSize, - outputChannels, - trimStartFrames, - trimEndFrames); + sink.configure(inputFormat, specifiedBufferSize, outputChannels); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 8a61053574..4f69b03be1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -39,6 +39,7 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; import com.google.android.exoplayer2.mediacodec.MediaFormatUtil; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; @@ -86,7 +87,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean passthroughEnabled; private boolean codecNeedsDiscardChannelsWorkaround; private boolean codecNeedsEosBufferTimestampWorkaround; - @Nullable private Format passthroughFormat; + @Nullable private Format passthroughCodecFormat; @Nullable private Format inputFormat; private long currentPositionUs; private boolean allowFirstBufferPositionDiscontinuity; @@ -226,9 +227,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media && (format.drmInitData == null || MediaCodecUtil.getPassthroughDecoderInfo() != null)) { return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } - if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - && !audioSink.supportsOutput(format, format.pcmEncoding)) - || !audioSink.supportsOutput(format, C.ENCODING_PCM_16BIT)) { + if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) && !audioSink.supportsOutput(format)) + || !audioSink.supportsOutput( + format.buildUpon().setEncoding(C.ENCODING_PCM_16BIT).build())) { // Assume the decoder outputs 16-bit PCM, unless the input is raw. return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); } @@ -304,7 +305,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate); codecAdapter.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); // Store the input MIME type if we're using the passthrough codec. - passthroughFormat = passthroughEnabled ? format : null; + passthroughCodecFormat = passthroughEnabled ? format : null; } @Override @@ -339,7 +340,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media return Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType) && oldFormat.channelCount == newFormat.channelCount && oldFormat.sampleRate == newFormat.sampleRate - && oldFormat.pcmEncoding == newFormat.pcmEncoding + && oldFormat.encoding == newFormat.encoding && oldFormat.initializationDataEquals(newFormat) && !MimeTypes.AUDIO_OPUS.equals(oldFormat.sampleMimeType); } @@ -385,40 +386,39 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void configureOutput(Format outputFormat) throws ExoPlaybackException { - @C.Encoding int encoding; - MediaFormat mediaFormat; - int channelCount; - int sampleRate; - if (passthroughFormat != null) { - encoding = getPassthroughEncoding(passthroughFormat); - channelCount = passthroughFormat.channelCount; - sampleRate = passthroughFormat.sampleRate; + Format audioSinkInputFormat; + if (passthroughCodecFormat != null) { + @C.Encoding int passthroughEncoding = getPassthroughEncoding(passthroughCodecFormat); + // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. + Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); + audioSinkInputFormat = outputFormat.buildUpon().setEncoding(passthroughEncoding).build(); } else { - mediaFormat = getCodec().getOutputFormat(); + MediaFormat mediaFormat = getCodec().getOutputFormat(); + @C.Encoding int encoding; if (mediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { encoding = Util.getPcmEncoding(mediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); } else { encoding = getPcmEncoding(outputFormat); } - channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); - sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE); + audioSinkInputFormat = + outputFormat + .buildUpon() + .setEncoding(encoding) + .setChannelCount(mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)) + .setSampleRate(mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)) + .build(); } @Nullable int[] channelMap = null; - if (codecNeedsDiscardChannelsWorkaround && channelCount == 6 && outputFormat.channelCount < 6) { + if (codecNeedsDiscardChannelsWorkaround + && audioSinkInputFormat.channelCount == 6 + && outputFormat.channelCount < 6) { channelMap = new int[outputFormat.channelCount]; for (int i = 0; i < outputFormat.channelCount; i++) { channelMap[i] = i; } } try { - audioSink.configure( - encoding, - channelCount, - sampleRate, - /* specifiedBufferSize= */ 0, - channelMap, - outputFormat.encoderDelay, - outputFormat.encoderPadding); + audioSink.configure(audioSinkInputFormat, /* specifiedBufferSize= */ 0, channelMap); } catch (AudioSink.ConfigurationException e) { throw createRendererException(e, outputFormat); } @@ -426,16 +426,12 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void onOutputPassthroughFormatChanged(Format outputFormat) throws ExoPlaybackException { - @C.Encoding int encoding = getPassthroughEncoding(outputFormat); + @C.Encoding int passthroughEncoding = getPassthroughEncoding(outputFormat); + // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. + Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); + Format format = outputFormat.buildUpon().setEncoding(passthroughEncoding).build(); try { - audioSink.configure( - encoding, - outputFormat.channelCount, - outputFormat.sampleRate, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - outputFormat.encoderDelay, - outputFormat.encoderPadding); + audioSink.configure(format, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); } catch (AudioSink.ConfigurationException e) { throw createRendererException(e, outputFormat); } @@ -458,16 +454,22 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { // E-AC3 JOC is object-based so the output channel count is arbitrary. - Format eAc3JocFormat = format.buildUpon().setChannelCount(Format.NO_VALUE).build(); - if (audioSink.supportsOutput(eAc3JocFormat, C.ENCODING_E_AC3_JOC)) { - return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC, format.codecs); + Format eAc3JocFormat = + format + .buildUpon() + .setChannelCount(Format.NO_VALUE) + .setEncoding(C.ENCODING_E_AC3_JOC) + .build(); + if (audioSink.supportsOutput(eAc3JocFormat)) { + return C.ENCODING_E_AC3_JOC; } // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. mimeType = MimeTypes.AUDIO_E_AC3; } @C.Encoding int encoding = MimeTypes.getEncoding(mimeType, format.codecs); - if (audioSink.supportsOutput(format, encoding)) { + Format passthroughFormat = format.buildUpon().setEncoding(encoding).build(); + if (audioSink.supportsOutput(passthroughFormat)) { return encoding; } else { return C.ENCODING_INVALID; @@ -844,7 +846,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media // If the format is anything other than PCM then we assume that the audio decoder will output // 16-bit PCM. return MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - ? format.pcmEncoding + ? format.encoding : C.ENCODING_PCM_16BIT; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java index f630c267e6..9e676c9978 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java @@ -45,7 +45,7 @@ import java.nio.ByteBuffer; * * @param trimStartFrames The number of audio frames to trim from the start of audio. * @param trimEndFrames The number of audio frames to trim from the end of audio. - * @see AudioSink#configure(int, int, int, int, int[], int, int) + * @see AudioSink#configure(com.google.android.exoplayer2.Format, int, int[]) */ public void setTrimFrameCount(int trimStartFrames, int trimEndFrames) { this.trimStartFrames = trimStartFrames; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index b3653bbc29..ed5db6634f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -87,7 +87,7 @@ public final class SilenceMediaSource extends BaseMediaSource { .setSampleMimeType(MimeTypes.AUDIO_RAW) .setChannelCount(CHANNEL_COUNT) .setSampleRate(SAMPLE_RATE_HZ) - .setPcmEncoding(PCM_ENCODING) + .setEncoding(PCM_ENCODING) .build(); private static final MediaItem MEDIA_ITEM = new MediaItem.Builder() diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index 7102bcd5ea..d34ef9d06f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -207,13 +207,15 @@ public final class DefaultAudioSinkTest { @Config(minSdk = OLDEST_SDK, maxSdk = 20) @Test public void doesNotSupportFloatOutputBeforeApi21() { - assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isFalse(); + Format floatFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build(); + assertThat(defaultAudioSink.supportsOutput(floatFormat)).isFalse(); } @Config(minSdk = 21, maxSdk = TARGET_SDK) @Test public void supportsFloatOutputFromApi21() { - assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_PCM_FLOAT)).isTrue(); + Format floatFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build(); + assertThat(defaultAudioSink.supportsOutput(floatFormat)).isTrue(); } @Test @@ -221,7 +223,8 @@ public final class DefaultAudioSinkTest { DefaultAudioSink defaultAudioSink = new DefaultAudioSink( new AudioCapabilities(new int[] {C.ENCODING_AAC_LC}, 2), new AudioProcessor[0]); - assertThat(defaultAudioSink.supportsOutput(STEREO_44_1_FORMAT, C.ENCODING_AAC_LC)).isFalse(); + Format aacLcFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_AAC_LC).build(); + assertThat(defaultAudioSink.supportsOutput(aacLcFormat)).isFalse(); } private void configureDefaultAudioSink(int channelCount) throws AudioSink.ConfigurationException { @@ -230,14 +233,15 @@ public final class DefaultAudioSinkTest { private void configureDefaultAudioSink(int channelCount, int trimStartFrames, int trimEndFrames) throws AudioSink.ConfigurationException { - defaultAudioSink.configure( - C.ENCODING_PCM_16BIT, - channelCount, - SAMPLE_RATE_44_1, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - /* trimStartFrames= */ trimStartFrames, - /* trimEndFrames= */ trimEndFrames); + Format format = + new Format.Builder() + .setEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(channelCount) + .setSampleRate(SAMPLE_RATE_44_1) + .setEncoderDelay(trimStartFrames) + .setEncoderPadding(trimEndFrames) + .build(); + defaultAudioSink.configure(format, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); } /** Creates a one second silence buffer for 44.1 kHz stereo 16-bit audio. */ diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java index 717d6a50d1..9ba8c9f2d2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -53,7 +53,7 @@ public class MediaCodecAudioRendererTest { private static final Format AUDIO_AAC = new Format.Builder() .setSampleMimeType(MimeTypes.AUDIO_AAC) - .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setEncoding(C.ENCODING_PCM_16BIT) .setChannelCount(2) .setSampleRate(44100) .setEncoderDelay(100) @@ -143,24 +143,10 @@ public class MediaCodecAudioRendererTest { } while (!mediaCodecAudioRenderer.isEnded()); verify(audioSink) - .configure( - AUDIO_AAC.pcmEncoding, - AUDIO_AAC.channelCount, - AUDIO_AAC.sampleRate, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - AUDIO_AAC.encoderDelay, - AUDIO_AAC.encoderPadding); + .configure(AUDIO_AAC, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); verify(audioSink) - .configure( - changedFormat.pcmEncoding, - changedFormat.channelCount, - changedFormat.sampleRate, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - changedFormat.encoderDelay, - changedFormat.encoderPadding); + .configure(changedFormat, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); } @Test @@ -205,24 +191,10 @@ public class MediaCodecAudioRendererTest { } while (!mediaCodecAudioRenderer.isEnded()); verify(audioSink) - .configure( - AUDIO_AAC.pcmEncoding, - AUDIO_AAC.channelCount, - AUDIO_AAC.sampleRate, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - AUDIO_AAC.encoderDelay, - AUDIO_AAC.encoderPadding); + .configure(AUDIO_AAC, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); verify(audioSink) - .configure( - changedFormat.pcmEncoding, - changedFormat.channelCount, - changedFormat.sampleRate, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - changedFormat.encoderDelay, - changedFormat.encoderPadding); + .configure(changedFormat, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java index ce8dce716a..ca5ba9a878 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java @@ -34,7 +34,7 @@ public final class C2Mp3TimestampTrackerTest { private static final Format AUDIO_MP3 = new Format.Builder() .setSampleMimeType(MimeTypes.AUDIO_MPEG) - .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setEncoding(C.ENCODING_PCM_16BIT) .setChannelCount(2) .setSampleRate(44_100) .build(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index de6d1c19c6..8634e65176 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -2098,7 +2098,7 @@ public class MatroskaExtractor implements Extractor { formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) - .setPcmEncoding(pcmEncoding); + .setEncoding(pcmEncoding); } else if (MimeTypes.isVideo(mimeType)) { type = C.TRACK_TYPE_VIDEO; if (displayUnit == Track.DISPLAY_UNIT_PIXELS) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index c5395c94af..85c5958401 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -294,7 +294,24 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; long timestampTimeUnits = 0; long duration; - if (!isFixedSampleSizeRawAudio) { + if (isFixedSampleSizeRawAudio) { + long[] chunkOffsetsBytes = new long[chunkIterator.length]; + int[] chunkSampleCounts = new int[chunkIterator.length]; + while (chunkIterator.moveNext()) { + chunkOffsetsBytes[chunkIterator.index] = chunkIterator.offset; + chunkSampleCounts[chunkIterator.index] = chunkIterator.numSamples; + } + int fixedSampleSize = Util.getPcmFrameSize(track.format.encoding, track.format.channelCount); + FixedSampleSizeRechunker.Results rechunkedResults = + FixedSampleSizeRechunker.rechunk( + fixedSampleSize, chunkOffsetsBytes, chunkSampleCounts, timestampDeltaInTimeUnits); + offsets = rechunkedResults.offsets; + sizes = rechunkedResults.sizes; + maximumSize = rechunkedResults.maximumSize; + timestamps = rechunkedResults.timestamps; + flags = rechunkedResults.flags; + duration = rechunkedResults.duration; + } else { offsets = new long[sampleCount]; sizes = new int[sampleCount]; timestamps = new long[sampleCount]; @@ -404,23 +421,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; + remainingSamplesAtTimestampOffset + (!isCttsValid ? ", ctts invalid" : "")); } - } else { - long[] chunkOffsetsBytes = new long[chunkIterator.length]; - int[] chunkSampleCounts = new int[chunkIterator.length]; - while (chunkIterator.moveNext()) { - chunkOffsetsBytes[chunkIterator.index] = chunkIterator.offset; - chunkSampleCounts[chunkIterator.index] = chunkIterator.numSamples; - } - int fixedSampleSize = - Util.getPcmFrameSize(track.format.pcmEncoding, track.format.channelCount); - FixedSampleSizeRechunker.Results rechunkedResults = FixedSampleSizeRechunker.rechunk( - fixedSampleSize, chunkOffsetsBytes, chunkSampleCounts, timestampDeltaInTimeUnits); - offsets = rechunkedResults.offsets; - sizes = rechunkedResults.sizes; - maximumSize = rechunkedResults.maximumSize; - timestamps = rechunkedResults.timestamps; - flags = rechunkedResults.flags; - duration = rechunkedResults.duration; } long durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, track.timescale); @@ -1303,7 +1303,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; .setCodecs(codecs) .setChannelCount(channelCount) .setSampleRate(sampleRate) - .setPcmEncoding(pcmEncoding) + .setEncoding(pcmEncoding) .setInitializationData( initializationData == null ? null : Collections.singletonList(initializationData)) .setDrmInitData(drmInitData) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java index 1d7b6b9c6e..ffd5a19e0d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java @@ -231,7 +231,7 @@ public final class WavExtractor implements Extractor { .setMaxInputSize(targetSampleSizeBytes) .setChannelCount(header.numChannels) .setSampleRate(header.frameRateHz) - .setPcmEncoding(pcmEncoding) + .setEncoding(pcmEncoding) .build(); } @@ -373,7 +373,7 @@ public final class WavExtractor implements Extractor { .setMaxInputSize(numOutputFramesToBytes(targetSampleSizeFrames, numChannels)) .setChannelCount(header.numChannels) .setSampleRate(header.frameRateHz) - .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setEncoding(C.ENCODING_PCM_16BIT) .build(); } diff --git a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.0.dump b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.0.dump index 37eee44b2e..0f8c382b50 100644 --- a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.1.dump b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.1.dump index 2c299253e3..9abc1c3a87 100644 --- a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 853333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.2.dump b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.2.dump index 86d36c9e24..49bce9c197 100644 --- a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1792000 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.3.dump b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.3.dump index 2ad86792ff..e266f35ea5 100644 --- a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 2645333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.unknown_length.dump index 37eee44b2e..0f8c382b50 100644 --- a/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_no_min_max_frame_size_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_num_samples_raw.0.dump b/testdata/src/test/assets/flac/bear_no_num_samples_raw.0.dump index 9615182d68..9086949bdb 100644 --- a/testdata/src/test/assets/flac/bear_no_num_samples_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_no_num_samples_raw.0.dump @@ -14,7 +14,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_num_samples_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_no_num_samples_raw.unknown_length.dump index 9615182d68..9086949bdb 100644 --- a/testdata/src/test/assets/flac/bear_no_num_samples_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_no_num_samples_raw.unknown_length.dump @@ -14,7 +14,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.0.dump b/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.0.dump index 34a2535c7a..7a16cd10d9 100644 --- a/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.0.dump @@ -13,7 +13,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.unknown_length.dump index 34a2535c7a..7a16cd10d9 100644 --- a/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_no_seek_table_no_num_samples_raw.unknown_length.dump @@ -13,7 +13,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.0.dump b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.0.dump index 172f9e44ec..b33ddb2766 100644 --- a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.1.dump b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.1.dump index cacb89d8d1..bd59e70938 100644 --- a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 853333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.2.dump b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.2.dump index 8285b73eca..5f2899a4f4 100644 --- a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1792000 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.3.dump b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.3.dump index 3223ab2a34..7819815e34 100644 --- a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 2730666 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.unknown_length.dump index 0c9b3e7eb2..9cd861ac8f 100644 --- a/testdata/src/test/assets/flac/bear_one_metadata_block_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_one_metadata_block_raw.unknown_length.dump @@ -13,7 +13,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_raw.0.dump b/testdata/src/test/assets/flac/bear_raw.0.dump index 37eee44b2e..0f8c382b50 100644 --- a/testdata/src/test/assets/flac/bear_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_raw.1.dump b/testdata/src/test/assets/flac/bear_raw.1.dump index 2c299253e3..9abc1c3a87 100644 --- a/testdata/src/test/assets/flac/bear_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 853333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_raw.2.dump b/testdata/src/test/assets/flac/bear_raw.2.dump index 86d36c9e24..49bce9c197 100644 --- a/testdata/src/test/assets/flac/bear_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1792000 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_raw.3.dump b/testdata/src/test/assets/flac/bear_raw.3.dump index 2ad86792ff..e266f35ea5 100644 --- a/testdata/src/test/assets/flac/bear_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 2645333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_raw.unknown_length.dump index 37eee44b2e..0f8c382b50 100644 --- a/testdata/src/test/assets/flac/bear_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.0.dump b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.0.dump index 7c72df42b5..04e47bb833 100644 --- a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 18432 channelCount = 2 sampleRate = 44000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.1.dump b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.1.dump index 7da240be3d..786ffc5cad 100644 --- a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 18432 channelCount = 2 sampleRate = 44000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 837818 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.2.dump b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.2.dump index 3943c77600..84d043ca85 100644 --- a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 18432 channelCount = 2 sampleRate = 44000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1780363 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.3.dump b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.3.dump index 18231418b9..79586bb1e8 100644 --- a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 18432 channelCount = 2 sampleRate = 44000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 2618181 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.unknown_length.dump index 7c72df42b5..04e47bb833 100644 --- a/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_uncommon_sample_rate_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 18432 channelCount = 2 sampleRate = 44000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.0.dump b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.0.dump index fcfa917208..e5b9a095bf 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.1.dump b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.1.dump index ad1c171a9a..a6a6ee7b06 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 853333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.2.dump b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.2.dump index e5f625e576..b65edd3f13 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1792000 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.3.dump b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.3.dump index 1625462f01..face737d1b 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 2645333 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.unknown_length.dump index fcfa917208..e5b9a095bf 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_disabled_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.0.dump b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.0.dump index ca9f1a74a1..932e3437ed 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] sample 0: time = 0 diff --git a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.1.dump b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.1.dump index 36314d9433..184fe1ba42 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] sample 0: time = 853333 diff --git a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.2.dump b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.2.dump index 0e8cc73341..b2b9a31068 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] sample 0: time = 1792000 diff --git a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.3.dump b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.3.dump index 8ef6f9cb33..c495780873 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] sample 0: time = 2645333 diff --git a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.unknown_length.dump index ca9f1a74a1..932e3437ed 100644 --- a/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_with_id3_enabled_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=] sample 0: time = 0 diff --git a/testdata/src/test/assets/flac/bear_with_picture_raw.0.dump b/testdata/src/test/assets/flac/bear_with_picture_raw.0.dump index 0ca949ec09..15269ee2cb 100644 --- a/testdata/src/test/assets/flac/bear_with_picture_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_with_picture_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[Picture: mimeType=image/png, description=] sample 0: time = 0 diff --git a/testdata/src/test/assets/flac/bear_with_picture_raw.1.dump b/testdata/src/test/assets/flac/bear_with_picture_raw.1.dump index 075fcec267..c37f27c7df 100644 --- a/testdata/src/test/assets/flac/bear_with_picture_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_with_picture_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[Picture: mimeType=image/png, description=] sample 0: time = 853333 diff --git a/testdata/src/test/assets/flac/bear_with_picture_raw.2.dump b/testdata/src/test/assets/flac/bear_with_picture_raw.2.dump index a49beeed80..6876396bd5 100644 --- a/testdata/src/test/assets/flac/bear_with_picture_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_with_picture_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[Picture: mimeType=image/png, description=] sample 0: time = 1792000 diff --git a/testdata/src/test/assets/flac/bear_with_picture_raw.3.dump b/testdata/src/test/assets/flac/bear_with_picture_raw.3.dump index 22330e462a..863e5282b4 100644 --- a/testdata/src/test/assets/flac/bear_with_picture_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_with_picture_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[Picture: mimeType=image/png, description=] sample 0: time = 2645333 diff --git a/testdata/src/test/assets/flac/bear_with_picture_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_with_picture_raw.unknown_length.dump index 0ca949ec09..15269ee2cb 100644 --- a/testdata/src/test/assets/flac/bear_with_picture_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_with_picture_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[Picture: mimeType=image/png, description=] sample 0: time = 0 diff --git a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.0.dump b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.0.dump index d6faa106c6..267c7f0663 100644 --- a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.0.dump +++ b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[VC: TITLE=test title, VC: ARTIST=test artist] sample 0: time = 0 diff --git a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.1.dump b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.1.dump index 7e2bac3904..8f032da3fc 100644 --- a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.1.dump +++ b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[VC: TITLE=test title, VC: ARTIST=test artist] sample 0: time = 853333 diff --git a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.2.dump b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.2.dump index 642a7a973a..663b45a631 100644 --- a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.2.dump +++ b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[VC: TITLE=test title, VC: ARTIST=test artist] sample 0: time = 1792000 diff --git a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.3.dump b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.3.dump index 262c5dde56..7a27bb4e4b 100644 --- a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.3.dump +++ b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[VC: TITLE=test title, VC: ARTIST=test artist] sample 0: time = 2645333 diff --git a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.unknown_length.dump b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.unknown_length.dump index d6faa106c6..267c7f0663 100644 --- a/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.unknown_length.dump +++ b/testdata/src/test/assets/flac/bear_with_vorbis_comments_raw.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 16384 channelCount = 2 sampleRate = 48000 - pcmEncoding = 2 + encoding = 2 metadata = entries=[VC: TITLE=test title, VC: ARTIST=test artist] sample 0: time = 0 diff --git a/testdata/src/test/assets/wav/sample.wav.0.dump b/testdata/src/test/assets/wav/sample.wav.0.dump index 8b6c4a5c60..12efc0ae24 100644 --- a/testdata/src/test/assets/wav/sample.wav.0.dump +++ b/testdata/src/test/assets/wav/sample.wav.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/wav/sample.wav.1.dump b/testdata/src/test/assets/wav/sample.wav.1.dump index 65d3586462..fb0f8202ef 100644 --- a/testdata/src/test/assets/wav/sample.wav.1.dump +++ b/testdata/src/test/assets/wav/sample.wav.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 333333 flags = 1 diff --git a/testdata/src/test/assets/wav/sample.wav.2.dump b/testdata/src/test/assets/wav/sample.wav.2.dump index bd9c8629e2..bdae910f97 100644 --- a/testdata/src/test/assets/wav/sample.wav.2.dump +++ b/testdata/src/test/assets/wav/sample.wav.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 666666 flags = 1 diff --git a/testdata/src/test/assets/wav/sample.wav.3.dump b/testdata/src/test/assets/wav/sample.wav.3.dump index ecf7512edc..f30bd85a83 100644 --- a/testdata/src/test/assets/wav/sample.wav.3.dump +++ b/testdata/src/test/assets/wav/sample.wav.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1000000 flags = 1 diff --git a/testdata/src/test/assets/wav/sample.wav.unknown_length.dump b/testdata/src/test/assets/wav/sample.wav.unknown_length.dump index 8b6c4a5c60..12efc0ae24 100644 --- a/testdata/src/test/assets/wav/sample.wav.unknown_length.dump +++ b/testdata/src/test/assets/wav/sample.wav.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.0.dump b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.0.dump index 992e8d06c2..8b6df6dfbb 100644 --- a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.0.dump +++ b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.0.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.1.dump b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.1.dump index b21964fcd6..48dc91dc20 100644 --- a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.1.dump +++ b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.1.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 339395 flags = 1 diff --git a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.2.dump b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.2.dump index d478a25fe7..8b15562381 100644 --- a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.2.dump +++ b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.2.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 678790 flags = 1 diff --git a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.3.dump b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.3.dump index 6512731b9e..805133bb5b 100644 --- a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.3.dump +++ b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.3.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 1018185 flags = 1 diff --git a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.unknown_length.dump b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.unknown_length.dump index 992e8d06c2..8b6df6dfbb 100644 --- a/testdata/src/test/assets/wav/sample_ima_adpcm.wav.unknown_length.dump +++ b/testdata/src/test/assets/wav/sample_ima_adpcm.wav.unknown_length.dump @@ -16,7 +16,7 @@ track 0: maxInputSize = 8820 channelCount = 1 sampleRate = 44100 - pcmEncoding = 2 + encoding = 2 sample 0: time = 0 flags = 1 diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java index 963d940d0d..0982340b85 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertWithMessage; import android.content.Context; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.ForwardingAudioSink; import com.google.android.exoplayer2.util.Assertions; @@ -52,25 +53,12 @@ public final class CapturingAudioSink extends ForwardingAudioSink implements Dum } @Override - public void configure( - int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { interceptedData.add( - new DumpableConfiguration(inputEncoding, inputChannelCount, inputSampleRate)); - super.configure( - inputEncoding, - inputChannelCount, - inputSampleRate, - specifiedBufferSize, - outputChannels, - trimStartFrames, - trimEndFrames); + new DumpableConfiguration( + inputFormat.encoding, inputFormat.channelCount, inputFormat.sampleRate)); + super.configure(inputFormat, specifiedBufferSize, outputChannels); } @Override diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java index 236eef0b60..5c225c8aeb 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java @@ -312,7 +312,7 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { addIfNonDefault(dumper, "pixelWidthHeightRatio", format -> format.pixelWidthHeightRatio); addIfNonDefault(dumper, "channelCount", format -> format.channelCount); addIfNonDefault(dumper, "sampleRate", format -> format.sampleRate); - addIfNonDefault(dumper, "pcmEncoding", format -> format.pcmEncoding); + addIfNonDefault(dumper, "encoding", format -> format.encoding); addIfNonDefault(dumper, "encoderDelay", format -> format.encoderDelay); addIfNonDefault(dumper, "encoderPadding", format -> format.encoderPadding); addIfNonDefault(dumper, "subsampleOffsetUs", format -> format.subsampleOffsetUs); From 159c77919ac18be68cc8a1123d9e238b00b17027 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 29 Jun 2020 12:48:55 +0100 Subject: [PATCH 0560/1052] Bump release to 2.11.7 PiperOrigin-RevId: 318790917 --- RELEASENOTES.md | 7 +++++-- constants.gradle | 4 ++-- .../google/android/exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2408914392..87dae7f0b9 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -215,8 +215,11 @@ manipulation API. * Demo app: Retain previous position in list of samples. * Add Guava dependency. -* IMA extension: Fix the way 'content complete' is handled to avoid repeatedly - refreshing the timeline after playback ends. + +### 2.11.7 (2020-06-29) ### + +* IMA extension: Fix the way postroll "content complete" notifications are + handled to avoid repeatedly refreshing the timeline after playback ends. ### 2.11.6 (2020-06-19) ### diff --git a/constants.gradle b/constants.gradle index 22766d40a0..c95315df6d 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.6' - releaseVersionCode = 2011006 + releaseVersion = '2.11.7' + releaseVersionCode = 2011007 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved. Also fix TODOs in UtilTest. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index f35c3ac498..35b6199cd3 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.6"; + public static final String VERSION = "2.11.7"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.6"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.7"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011006; + public static final int VERSION_INT = 2011007; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From e9249c3a732a85a67f50071e80a2cbcac9a26bd6 Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 29 Jun 2020 13:19:34 +0100 Subject: [PATCH 0561/1052] Add methods to get the current sample info in FragmentedMp4Extractor This enhances readability, particularly as those methods will become more complex when partially fragmented media will be supported. PiperOrigin-RevId: 318795536 --- .../extractor/mp4/FragmentedMp4Extractor.java | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index cf84eab20d..4496d8784e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -1246,8 +1246,7 @@ public class FragmentedMp4Extractor implements Extractor { return false; } - long nextDataPosition = currentTrackBundle.fragment - .trunDataPosition[currentTrackBundle.currentTrackRunIndex]; + long nextDataPosition = currentTrackBundle.getCurrentSampleOffset(); // We skip bytes preceding the next sample to read. int bytesToSkip = (int) (nextDataPosition - input.getPosition()); if (bytesToSkip < 0) { @@ -1259,8 +1258,7 @@ public class FragmentedMp4Extractor implements Extractor { this.currentTrackBundle = currentTrackBundle; } - sampleSize = currentTrackBundle.fragment - .sampleSizeTable[currentTrackBundle.currentSampleIndex]; + sampleSize = currentTrackBundle.getCurrentSampleSize(); if (currentTrackBundle.currentSampleIndex < currentTrackBundle.firstSampleToOutputIndex) { input.skipFully(sampleSize); @@ -1293,11 +1291,9 @@ public class FragmentedMp4Extractor implements Extractor { sampleCurrentNalBytesRemaining = 0; } - TrackFragment fragment = currentTrackBundle.fragment; Track track = currentTrackBundle.track; TrackOutput output = currentTrackBundle.output; - int sampleIndex = currentTrackBundle.currentSampleIndex; - long sampleTimeUs = fragment.getSamplePresentationTimeUs(sampleIndex); + long sampleTimeUs = currentTrackBundle.getCurrentSamplePresentationTimeUs(); if (timestampAdjuster != null) { sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); } @@ -1362,14 +1358,12 @@ public class FragmentedMp4Extractor implements Extractor { } } - @C.BufferFlags int sampleFlags = fragment.sampleIsSyncFrameTable[sampleIndex] - ? C.BUFFER_FLAG_KEY_FRAME : 0; + @C.BufferFlags int sampleFlags = currentTrackBundle.getCurrentSampleFlags(); // Encryption data. TrackOutput.CryptoData cryptoData = null; TrackEncryptionBox encryptionBox = currentTrackBundle.getEncryptionBoxIfEncrypted(); if (encryptionBox != null) { - sampleFlags |= C.BUFFER_FLAG_ENCRYPTED; cryptoData = encryptionBox.cryptoData; } @@ -1418,7 +1412,7 @@ public class FragmentedMp4Extractor implements Extractor { if (trackBundle.currentTrackRunIndex == trackBundle.fragment.trunCount) { // This track fragment contains no more runs in the next mdat box. } else { - long trunOffset = trackBundle.fragment.trunDataPosition[trackBundle.currentTrackRunIndex]; + long trunOffset = trackBundle.getCurrentSampleOffset(); if (trunOffset < nextTrackRunOffset) { nextTrackBundle = trackBundle; nextTrackRunOffset = trunOffset; @@ -1556,6 +1550,31 @@ public class FragmentedMp4Extractor implements Extractor { } } + /** Returns the presentation time of the current sample in microseconds. */ + public long getCurrentSamplePresentationTimeUs() { + return fragment.getSamplePresentationTimeUs(currentSampleIndex); + } + + /** Returns the byte offset of the current sample. */ + public long getCurrentSampleOffset() { + return fragment.trunDataPosition[currentTrackRunIndex]; + } + + /** Returns the size of the current sample in bytes. */ + public int getCurrentSampleSize() { + return fragment.sampleSizeTable[currentSampleIndex]; + } + + /** Returns the {@link C.BufferFlags} corresponding to the the current sample. */ + @C.BufferFlags + public int getCurrentSampleFlags() { + int flags = fragment.sampleIsSyncFrameTable[currentSampleIndex] ? C.BUFFER_FLAG_KEY_FRAME : 0; + if (getEncryptionBoxIfEncrypted() != null) { + flags |= C.BUFFER_FLAG_ENCRYPTED; + } + return flags; + } + /** * Advances the indices in the bundle to point to the next sample in the current fragment. If * the current sample is the last one in the current fragment, then the advanced state will be @@ -1668,7 +1687,7 @@ public class FragmentedMp4Extractor implements Extractor { } /** Skips the encryption data for the current sample. */ - private void skipSampleEncryptionData() { + public void skipSampleEncryptionData() { @Nullable TrackEncryptionBox encryptionBox = getEncryptionBoxIfEncrypted(); if (encryptionBox == null) { return; @@ -1684,7 +1703,7 @@ public class FragmentedMp4Extractor implements Extractor { } @Nullable - private TrackEncryptionBox getEncryptionBoxIfEncrypted() { + public TrackEncryptionBox getEncryptionBoxIfEncrypted() { int sampleDescriptionIndex = fragment.header.sampleDescriptionIndex; @Nullable TrackEncryptionBox encryptionBox = From 8fcbbb09aff3d05a3e60fc052d9c84f20b80f2db Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 29 Jun 2020 12:48:55 +0100 Subject: [PATCH 0562/1052] Bump release to 2.11.7 PiperOrigin-RevId: 318790917 --- RELEASENOTES.md | 9 +++++---- constants.gradle | 4 ++-- .../google/android/exoplayer2/ExoPlayerLibraryInfo.java | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b890defb5d..d99656b5a9 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,10 +1,11 @@ # Release notes # -### 2.11.6 (2020-06-24) ### -* IMA extension: Fix the way 'content complete' is handled to avoid repeatedly - refreshing the timeline after playback ends. +### 2.11.7 (2020-06-29) ### -### 2.11.6 (2020-06-19) ### +* IMA extension: Fix the way postroll "content complete" notifications are + handled to avoid repeatedly refreshing the timeline after playback ends. + +### 2.11.6 (2020-06-24) ### * UI: Prevent `PlayerView` from temporarily hiding the video surface when seeking to an unprepared period within the current window. For example when diff --git a/constants.gradle b/constants.gradle index 7b6298e9d4..9d38d82369 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.6' - releaseVersionCode = 2011006 + releaseVersion = '2.11.7' + releaseVersionCode = 2011007 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index f35c3ac498..35b6199cd3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.6"; + public static final String VERSION = "2.11.7"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.6"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.7"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011006; + public static final int VERSION_INT = 2011007; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} From 2eab6802c939ddefe660f016e6e70eb2da24b3ca Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 29 Jun 2020 13:55:27 +0100 Subject: [PATCH 0563/1052] Parse stbl in FragmentedMp4Extractor This will be necessary to support partially fragmented MP4s. PiperOrigin-RevId: 318798726 --- .../exoplayer2/extractor/mp4/AtomParsers.java | 200 +++++++++--------- .../extractor/mp4/FragmentedMp4Extractor.java | 75 ++++--- .../extractor/mp4/Mp4Extractor.java | 12 +- 3 files changed, 158 insertions(+), 129 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 85c5958401..13ffcc2ff1 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -37,6 +37,7 @@ import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.AvcConfig; import com.google.android.exoplayer2.video.DolbyVisionConfig; import com.google.android.exoplayer2.video.HevcConfig; +import com.google.common.base.Function; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -87,16 +88,23 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * * @param moov Moov atom to decode. * @param gaplessInfoHolder Holder to populate with gapless playback information. + * @param duration The duration in units of the timescale declared in the mvhd atom, or {@link + * C#TIME_UNSET} if the duration should be parsed from the tkhd atom. + * @param drmInitData {@link DrmInitData} to be included in the format, or {@code null}. * @param ignoreEditLists Whether to ignore any edit lists in the trak boxes. * @param isQuickTime True for QuickTime media. False otherwise. + * @param modifyTrackFunction A function to apply to the {@link Track Tracks} in the result. * @return A list of {@link TrackSampleTable} instances. * @throws ParserException Thrown if the trak atoms can't be parsed. */ public static List parseTraks( Atom.ContainerAtom moov, GaplessInfoHolder gaplessInfoHolder, + long duration, + @Nullable DrmInitData drmInitData, boolean ignoreEditLists, - boolean isQuickTime) + boolean isQuickTime, + Function modifyTrackFunction) throws ParserException { List trackSampleTables = new ArrayList<>(); for (int i = 0; i < moov.containerChildren.size(); i++) { @@ -106,13 +114,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } @Nullable Track track = - parseTrak( - atom, - moov.getLeafAtomOfType(Atom.TYPE_mvhd), - /* duration= */ C.TIME_UNSET, - /* drmInitData= */ null, - ignoreEditLists, - isQuickTime); + modifyTrackFunction.apply( + parseTrak( + atom, + moov.getLeafAtomOfType(Atom.TYPE_mvhd), + duration, + drmInitData, + ignoreEditLists, + isQuickTime)); if (track == null) { continue; } @@ -121,14 +130,95 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; .getContainerAtomOfType(Atom.TYPE_minf) .getContainerAtomOfType(Atom.TYPE_stbl); TrackSampleTable trackSampleTable = parseStbl(track, stblAtom, gaplessInfoHolder); - if (trackSampleTable.sampleCount == 0) { - continue; - } trackSampleTables.add(trackSampleTable); } return trackSampleTables; } + /** + * Parses a udta atom. + * + * @param udtaAtom The udta (user data) atom to decode. + * @param isQuickTime True for QuickTime media. False otherwise. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { + if (isQuickTime) { + // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and + // decode one. + return null; + } + ParsableByteArray udtaData = udtaAtom.data; + udtaData.setPosition(Atom.HEADER_SIZE); + while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { + int atomPosition = udtaData.getPosition(); + int atomSize = udtaData.readInt(); + int atomType = udtaData.readInt(); + if (atomType == Atom.TYPE_meta) { + udtaData.setPosition(atomPosition); + return parseUdtaMeta(udtaData, atomPosition + atomSize); + } + udtaData.setPosition(atomPosition + atomSize); + } + return null; + } + + /** + * Parses a metadata meta atom if it contains metadata with handler 'mdta'. + * + * @param meta The metadata atom to decode. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseMdtaFromMeta(Atom.ContainerAtom meta) { + @Nullable Atom.LeafAtom hdlrAtom = meta.getLeafAtomOfType(Atom.TYPE_hdlr); + @Nullable Atom.LeafAtom keysAtom = meta.getLeafAtomOfType(Atom.TYPE_keys); + @Nullable Atom.LeafAtom ilstAtom = meta.getLeafAtomOfType(Atom.TYPE_ilst); + if (hdlrAtom == null + || keysAtom == null + || ilstAtom == null + || parseHdlr(hdlrAtom.data) != TYPE_mdta) { + // There isn't enough information to parse the metadata, or the handler type is unexpected. + return null; + } + + // Parse metadata keys. + ParsableByteArray keys = keysAtom.data; + keys.setPosition(Atom.FULL_HEADER_SIZE); + int entryCount = keys.readInt(); + String[] keyNames = new String[entryCount]; + for (int i = 0; i < entryCount; i++) { + int entrySize = keys.readInt(); + keys.skipBytes(4); // keyNamespace + int keySize = entrySize - 8; + keyNames[i] = keys.readString(keySize); + } + + // Parse metadata items. + ParsableByteArray ilst = ilstAtom.data; + ilst.setPosition(Atom.HEADER_SIZE); + ArrayList entries = new ArrayList<>(); + while (ilst.bytesLeft() > Atom.HEADER_SIZE) { + int atomPosition = ilst.getPosition(); + int atomSize = ilst.readInt(); + int keyIndex = ilst.readInt() - 1; + if (keyIndex >= 0 && keyIndex < keyNames.length) { + String key = keyNames[keyIndex]; + @Nullable + Metadata.Entry entry = + MetadataUtil.parseMdtaMetadataEntryFromIlst(ilst, atomPosition + atomSize, key); + if (entry != null) { + entries.add(entry); + } + } else { + Log.w(TAG, "Skipped metadata with unknown key index: " + keyIndex); + } + ilst.setPosition(atomPosition + atomSize); + } + return entries.isEmpty() ? null : new Metadata(entries); + } + /** * Parses a trak atom (defined in ISO/IEC 14496-12). * @@ -143,7 +233,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @throws ParserException Thrown if the trak atom can't be parsed. */ @Nullable - public static Track parseTrak( + private static Track parseTrak( Atom.ContainerAtom trak, Atom.LeafAtom mvhd, long duration, @@ -201,7 +291,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @return Sample table described by the stbl atom. * @throws ParserException Thrown if the stbl atom can't be parsed. */ - public static TrackSampleTable parseStbl( + private static TrackSampleTable parseStbl( Track track, Atom.ContainerAtom stblAtom, GaplessInfoHolder gaplessInfoHolder) throws ParserException { SampleSizeBox sampleSizeBox; @@ -561,90 +651,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; editedDurationUs); } - /** - * Parses a udta atom. - * - * @param udtaAtom The udta (user data) atom to decode. - * @param isQuickTime True for QuickTime media. False otherwise. - * @return Parsed metadata, or null. - */ - @Nullable - public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { - if (isQuickTime) { - // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and - // decode one. - return null; - } - ParsableByteArray udtaData = udtaAtom.data; - udtaData.setPosition(Atom.HEADER_SIZE); - while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { - int atomPosition = udtaData.getPosition(); - int atomSize = udtaData.readInt(); - int atomType = udtaData.readInt(); - if (atomType == Atom.TYPE_meta) { - udtaData.setPosition(atomPosition); - return parseUdtaMeta(udtaData, atomPosition + atomSize); - } - udtaData.setPosition(atomPosition + atomSize); - } - return null; - } - - /** - * Parses a metadata meta atom if it contains metadata with handler 'mdta'. - * - * @param meta The metadata atom to decode. - * @return Parsed metadata, or null. - */ - @Nullable - public static Metadata parseMdtaFromMeta(Atom.ContainerAtom meta) { - @Nullable Atom.LeafAtom hdlrAtom = meta.getLeafAtomOfType(Atom.TYPE_hdlr); - @Nullable Atom.LeafAtom keysAtom = meta.getLeafAtomOfType(Atom.TYPE_keys); - @Nullable Atom.LeafAtom ilstAtom = meta.getLeafAtomOfType(Atom.TYPE_ilst); - if (hdlrAtom == null - || keysAtom == null - || ilstAtom == null - || parseHdlr(hdlrAtom.data) != TYPE_mdta) { - // There isn't enough information to parse the metadata, or the handler type is unexpected. - return null; - } - - // Parse metadata keys. - ParsableByteArray keys = keysAtom.data; - keys.setPosition(Atom.FULL_HEADER_SIZE); - int entryCount = keys.readInt(); - String[] keyNames = new String[entryCount]; - for (int i = 0; i < entryCount; i++) { - int entrySize = keys.readInt(); - keys.skipBytes(4); // keyNamespace - int keySize = entrySize - 8; - keyNames[i] = keys.readString(keySize); - } - - // Parse metadata items. - ParsableByteArray ilst = ilstAtom.data; - ilst.setPosition(Atom.HEADER_SIZE); - ArrayList entries = new ArrayList<>(); - while (ilst.bytesLeft() > Atom.HEADER_SIZE) { - int atomPosition = ilst.getPosition(); - int atomSize = ilst.readInt(); - int keyIndex = ilst.readInt() - 1; - if (keyIndex >= 0 && keyIndex < keyNames.length) { - String key = keyNames[keyIndex]; - @Nullable - Metadata.Entry entry = - MetadataUtil.parseMdtaMetadataEntryFromIlst(ilst, atomPosition + atomSize, key); - if (entry != null) { - entries.add(entry); - } - } else { - Log.w(TAG, "Skipped metadata with unknown key index: " + keyIndex); - } - ilst.setPosition(atomPosition + atomSize); - } - return entries.isEmpty() ? null : new Metadata(entries); - } - @Nullable private static Metadata parseUdtaMeta(ParsableByteArray meta, int limit) { meta.skipBytes(Atom.FULL_HEADER_SIZE); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 4496d8784e..30a1de8988 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static com.google.android.exoplayer2.extractor.mp4.AtomParsers.parseTraks; + import android.util.Pair; import android.util.SparseArray; import androidx.annotation.IntDef; @@ -31,6 +33,7 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.GaplessInfoHolder; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; @@ -479,33 +482,22 @@ public class FragmentedMp4Extractor implements Extractor { } } - // Construction of tracks. - SparseArray tracks = new SparseArray<>(); - int moovContainerChildrenSize = moov.containerChildren.size(); - for (int i = 0; i < moovContainerChildrenSize; i++) { - Atom.ContainerAtom atom = moov.containerChildren.get(i); - if (atom.type == Atom.TYPE_trak) { - @Nullable - Track track = - modifyTrack( - AtomParsers.parseTrak( - atom, - moov.getLeafAtomOfType(Atom.TYPE_mvhd), - duration, - drmInitData, - (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0, - false)); - if (track != null) { - tracks.put(track.id, track); - } - } - } + // Construction of tracks and sample tables. + List trackSampleTables = + parseTraks( + moov, + new GaplessInfoHolder(), + duration, + drmInitData, + /* ignoreEditLists= */ (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0, + /* isQuickTime= */ false, + this::modifyTrack); - int trackCount = tracks.size(); + int trackCount = trackSampleTables.size(); if (trackBundles.size() == 0) { // We need to create the track bundles. for (int i = 0; i < trackCount; i++) { - Track track = tracks.valueAt(i); + Track track = trackSampleTables.get(i).track; TrackBundle trackBundle = new TrackBundle(extractorOutput.track(i, track.type)); trackBundle.init(track, getDefaultSampleValues(defaultSampleValuesArray, track.id)); trackBundles.put(track.id, trackBundle); @@ -516,7 +508,7 @@ public class FragmentedMp4Extractor implements Extractor { } else { Assertions.checkState(trackBundles.size() == trackCount); for (int i = 0; i < trackCount; i++) { - Track track = tracks.valueAt(i); + Track track = trackSampleTables.get(i).track; trackBundles .get(track.id) .init(track, getDefaultSampleValues(defaultSampleValuesArray, track.id)); @@ -1447,13 +1439,34 @@ public class FragmentedMp4Extractor implements Extractor { /** Returns whether the extractor should decode a leaf atom with type {@code atom}. */ private static boolean shouldParseLeafAtom(int atom) { - return atom == Atom.TYPE_hdlr || atom == Atom.TYPE_mdhd || atom == Atom.TYPE_mvhd - || atom == Atom.TYPE_sidx || atom == Atom.TYPE_stsd || atom == Atom.TYPE_tfdt - || atom == Atom.TYPE_tfhd || atom == Atom.TYPE_tkhd || atom == Atom.TYPE_trex - || atom == Atom.TYPE_trun || atom == Atom.TYPE_pssh || atom == Atom.TYPE_saiz - || atom == Atom.TYPE_saio || atom == Atom.TYPE_senc || atom == Atom.TYPE_uuid - || atom == Atom.TYPE_sbgp || atom == Atom.TYPE_sgpd || atom == Atom.TYPE_elst - || atom == Atom.TYPE_mehd || atom == Atom.TYPE_emsg; + return atom == Atom.TYPE_hdlr + || atom == Atom.TYPE_mdhd + || atom == Atom.TYPE_mvhd + || atom == Atom.TYPE_sidx + || atom == Atom.TYPE_stsd + || atom == Atom.TYPE_stts + || atom == Atom.TYPE_ctts + || atom == Atom.TYPE_stsc + || atom == Atom.TYPE_stsz + || atom == Atom.TYPE_stz2 + || atom == Atom.TYPE_stco + || atom == Atom.TYPE_co64 + || atom == Atom.TYPE_stss + || atom == Atom.TYPE_tfdt + || atom == Atom.TYPE_tfhd + || atom == Atom.TYPE_tkhd + || atom == Atom.TYPE_trex + || atom == Atom.TYPE_trun + || atom == Atom.TYPE_pssh + || atom == Atom.TYPE_saiz + || atom == Atom.TYPE_saio + || atom == Atom.TYPE_senc + || atom == Atom.TYPE_uuid + || atom == Atom.TYPE_sbgp + || atom == Atom.TYPE_sgpd + || atom == Atom.TYPE_elst + || atom == Atom.TYPE_mehd + || atom == Atom.TYPE_emsg; } /** Returns whether the extractor should decode a container atom with type {@code atom}. */ diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index bc822c8212..13668404cf 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -409,11 +409,21 @@ public final class Mp4Extractor implements Extractor, SeekMap { boolean ignoreEditLists = (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0; List trackSampleTables = - parseTraks(moov, gaplessInfoHolder, ignoreEditLists, isQuickTime); + parseTraks( + moov, + gaplessInfoHolder, + /* duration= */ C.TIME_UNSET, + /* drmInitData= */ null, + ignoreEditLists, + isQuickTime, + /* modifyTrackFunction= */ track -> track); int trackCount = trackSampleTables.size(); for (int i = 0; i < trackCount; i++) { TrackSampleTable trackSampleTable = trackSampleTables.get(i); + if (trackSampleTable.sampleCount == 0) { + continue; + } Track track = trackSampleTable.track; long trackDurationUs = track.durationUs != C.TIME_UNSET ? track.durationUs : trackSampleTable.durationUs; From 314bc65d620521824f45c0bce4d1dcfcd4e50693 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 29 Jun 2020 14:36:57 +0100 Subject: [PATCH 0564/1052] Enable download parallelisation in demo app - Deprecate constructors that don't take an executor, to direct developers toward the new ones. Callers can trivially pass Runnable::run to one of the new ones if they want old behaviour. - Add comment explaining warning suppression added in the CL that added parallelised download support. Issue: #5978 PiperOrigin-RevId: 318803296 --- RELEASENOTES.md | 5 +++- .../exoplayer2/demo/DemoApplication.java | 7 ++++- .../offline/DefaultDownloaderFactory.java | 9 ++++-- .../exoplayer2/offline/DownloadManager.java | 29 ++++++++++++++++++- .../exoplayer2/util/RunnableFutureTask.java | 2 ++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 87dae7f0b9..63f8b2594d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -145,7 +145,10 @@ ([#7078](https://github.com/google/ExoPlayer/issues/7078)). * Remove generics from DRM components. * Downloads and caching: - * Merge downloads in `SegmentDownloader` to improve overall download speed + * Support passing an `Executor` to `DefaultDownloaderFactory` on which + data downloads are performed. + * Parallelize and merge downloads in `SegmentDownloader` to improve + overall download speed ([#5978](https://github.com/google/ExoPlayer/issues/5978)). * Support multiple non-overlapping write locks for the same key in `SimpleCache`. diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java index 86978a1613..39e64f8025 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java @@ -36,6 +36,7 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; +import java.util.concurrent.Executors; /** * Placeholder application to facilitate overriding Application methods for debugging and testing. @@ -129,7 +130,11 @@ public class DemoApplication extends MultiDexApplication { DOWNLOAD_TRACKER_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ true); downloadManager = new DownloadManager( - this, getDatabaseProvider(), getDownloadCache(), buildHttpDataSourceFactory()); + this, + getDatabaseProvider(), + getDownloadCache(), + buildHttpDataSourceFactory(), + Executors.newFixedThreadPool(/* nThreads= */ 6)); downloadTracker = new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadManager); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java index 67e2bd8c77..f7b12a349e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -79,7 +79,9 @@ public class DefaultDownloaderFactory implements DownloaderFactory { * * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which * downloads will be written. + * @deprecated Use {@link #DefaultDownloaderFactory(CacheDataSource.Factory, Executor)}. */ + @Deprecated public DefaultDownloaderFactory(CacheDataSource.Factory cacheDataSourceFactory) { this(cacheDataSourceFactory, Runnable::run); } @@ -89,9 +91,10 @@ public class DefaultDownloaderFactory implements DownloaderFactory { * * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which * downloads will be written. - * @param executor An {@link Executor} used to make requests for media being downloaded. Providing - * an {@link Executor} that uses multiple threads will speed up download tasks that can be - * split into smaller parts for parallel execution. + * @param executor An {@link Executor} used to download data. Passing {@code Runnable::run} will + * cause each download task to download data on its own thread. Passing an {@link Executor} + * that uses multiple threads will speed up download tasks that can be split into smaller + * parts for parallel execution. */ public DefaultDownloaderFactory( CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 5b80b64ad8..6b12ab3759 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -52,6 +52,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executor; /** * Manages downloads. @@ -197,16 +198,42 @@ public final class DownloadManager { * an {@link CacheEvictor} that will not evict downloaded content, for example {@link * NoOpCacheEvictor}. * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data. + * @deprecated Use {@link #DownloadManager(Context, DatabaseProvider, Cache, Factory, Executor)}. */ + @Deprecated public DownloadManager( Context context, DatabaseProvider databaseProvider, Cache cache, Factory upstreamFactory) { + this(context, databaseProvider, cache, upstreamFactory, Runnable::run); + } + + /** + * Constructs a {@link DownloadManager}. + * + * @param context Any context. + * @param databaseProvider Provides the SQLite database in which downloads are persisted. + * @param cache A cache to be used to store downloaded data. The cache should be configured with + * an {@link CacheEvictor} that will not evict downloaded content, for example {@link + * NoOpCacheEvictor}. + * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data. + * @param executor An {@link Executor} used to download data. Passing {@code Runnable::run} will + * cause each download task to download data on its own thread. Passing an {@link Executor} + * that uses multiple threads will speed up download tasks that can be split into smaller + * parts for parallel execution. + */ + public DownloadManager( + Context context, + DatabaseProvider databaseProvider, + Cache cache, + Factory upstreamFactory, + Executor executor) { this( context, new DefaultDownloadIndex(databaseProvider), new DefaultDownloaderFactory( new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(upstreamFactory))); + .setUpstreamDataSourceFactory(upstreamFactory), + executor)); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java b/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java index 9f06f40a67..9da5f09629 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java @@ -159,6 +159,8 @@ public abstract class RunnableFutureTask implements Runn // Do nothing. } + // The return value is guaranteed to be non-null if and only if R is a non-null type, but there's + // no way to assert this. Suppress the warning instead. @SuppressWarnings("return.type.incompatible") @UnknownNull private R getResult() throws ExecutionException { From 5c096acc297512b248ea30391626b81167b44d53 Mon Sep 17 00:00:00 2001 From: Juanky Soriano Date: Wed, 1 Jul 2020 00:13:30 -0500 Subject: [PATCH 0565/1052] Use default text size if captioning manager is disabled This fixes an issue where, even if captioning manager is disabled, the latest used captioning manager preference related to text size is being applied. In order to replicate: 1. Go to Captioning Preferences under device Settings and enable it 2. Change the text size to "very large" 3. Observe the selected text size is used for subtitles, for example in Youtube 4. Go to Captioning Preferences under device Settings and disable it 5. Observe the text size used for subtitles does not come back to normal, stays on "very large" --- .../java/com/google/android/exoplayer2/ui/SubtitleView.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index e066fa0f8a..d15bbfbc76 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -229,7 +229,9 @@ public final class SubtitleView extends FrameLayout implements TextOutput { * default size before API level 19. */ public void setUserDefaultTextSize() { - float fontScale = Util.SDK_INT >= 19 && !isInEditMode() ? getUserCaptionFontScaleV19() : 1f; + float fontScale = Util.SDK_INT >= 19 && isCaptionManagerEnabled() && !isInEditMode() + ? getUserCaptionFontScaleV19() + : 1f; setFractionalTextSize(DEFAULT_TEXT_SIZE_FRACTION * fontScale); } From 311d21bf8d2c53e05c4eaa502f2c1568a94af2f2 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 29 Jun 2020 16:53:27 +0100 Subject: [PATCH 0566/1052] Remove the multi-threading from DrmSessionManagerTest I don't need to keep a separate playback looper, I can just use ShadowLooper.idleMainLooper(). PiperOrigin-RevId: 318823190 --- .../drm/DefaultDrmSessionManagerTest.java | 101 +++++------------- 1 file changed, 25 insertions(+), 76 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java index 6905c631c7..73f68d1202 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java @@ -17,25 +17,17 @@ package com.google.android.exoplayer2.drm; import static com.google.common.truth.Truth.assertThat; -import android.os.Handler; -import android.os.HandlerThread; -import androidx.annotation.Nullable; +import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; import com.google.android.exoplayer2.testutil.TestUtil; -import com.google.android.exoplayer2.util.ConditionVariable; -import com.google.android.exoplayer2.util.Function; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.collect.ImmutableList; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.After; -import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; /** Tests for {@link DefaultDrmSessionManager} and {@link DefaultDrmSession}. */ // TODO: Test more branches: @@ -46,8 +38,6 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class DefaultDrmSessionManagerTest { - private static final int TIMEOUT_MS = 1_000; - private static final UUID DRM_SCHEME_UUID = UUID.nameUUIDFromBytes(TestUtil.createByteArray(7, 8, 9)); private static final ImmutableList DRM_SCHEME_DATAS = @@ -56,76 +46,35 @@ public class DefaultDrmSessionManagerTest { DRM_SCHEME_UUID, MimeTypes.VIDEO_MP4, /* data= */ TestUtil.createByteArray(1, 2, 3))); private static final DrmInitData DRM_INIT_DATA = new DrmInitData(DRM_SCHEME_DATAS); - private HandlerThread playbackThread; - private Handler playbackThreadHandler; - private MediaSourceEventDispatcher eventDispatcher; - private ConditionVariable keysLoaded; - - @Before - public void setUp() { - playbackThread = new HandlerThread("Test playback thread"); - playbackThread.start(); - playbackThreadHandler = new Handler(playbackThread.getLooper()); - eventDispatcher = new MediaSourceEventDispatcher(); - keysLoaded = TestUtil.createRobolectricConditionVariable(); - eventDispatcher.addEventListener( - playbackThreadHandler, - new DrmSessionEventListener() { - @Override - public void onDrmKeysLoaded( - int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { - keysLoaded.open(); - } - }, - DrmSessionEventListener.class); - } - - @After - public void tearDown() { - playbackThread.quitSafely(); - } - - @Test + @Test(timeout = 10_000) public void acquireSessionTriggersKeyLoadAndSessionIsOpened() throws Exception { FakeExoMediaDrm.LicenseServer licenseServer = FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); - keysLoaded.close(); - AtomicReference drmSession = new AtomicReference<>(); - playbackThreadHandler.post( - () -> { - DefaultDrmSessionManager drmSessionManager = - new DefaultDrmSessionManager.Builder() - .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) - .build(/* mediaDrmCallback= */ licenseServer); + DefaultDrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .build(/* mediaDrmCallback= */ licenseServer); + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); - drmSessionManager.prepare(); - drmSession.set( - drmSessionManager.acquireSession( - playbackThread.getLooper(), eventDispatcher, DRM_INIT_DATA)); - }); - - keysLoaded.block(TIMEOUT_MS); - - @DrmSession.State int state = post(drmSession.get(), DrmSession::getState); - assertThat(state).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); - Map keyStatus = post(drmSession.get(), DrmSession::queryKeyStatus); - assertThat(keyStatus) + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + assertThat(drmSession.queryKeyStatus()) .containsExactly(FakeExoMediaDrm.KEY_STATUS_KEY, FakeExoMediaDrm.KEY_STATUS_AVAILABLE); } - /** Call a function on {@code drmSession} on the playback thread and return the result. */ - private T post(DrmSession drmSession, Function fn) - throws InterruptedException { - AtomicReference result = new AtomicReference<>(); - ConditionVariable resultReady = TestUtil.createRobolectricConditionVariable(); - resultReady.close(); - playbackThreadHandler.post( - () -> { - result.set(fn.apply(drmSession)); - resultReady.open(); - }); - resultReady.block(TIMEOUT_MS); - return result.get(); + private static void waitForOpenedWithKeys(DrmSession drmSession) { + // Check the error first, so we get a meaningful failure if there's been an error. + assertThat(drmSession.getError()).isNull(); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED); + while (drmSession.getState() != DrmSession.STATE_OPENED_WITH_KEYS) { + // Allow the key response to be handled. + ShadowLooper.idleMainLooper(); + } } } From 2e749f70aeb98269fad65643fe1435e7b632cdb1 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 30 Jun 2020 10:25:49 +0100 Subject: [PATCH 0567/1052] Don't support upstream discard from spliced-in chunks. We can't restore the previous state of the remaining chunk, so we can't support discarding from spliced-in chunks. Mark this explicitly instead of attempting to discard from the previous chunk. PiperOrigin-RevId: 318983628 --- .../exoplayer2/source/hls/HlsMediaChunk.java | 59 ++++++++----------- .../source/hls/HlsSampleStreamWrapper.java | 41 +++++++++---- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 687f7f8ccb..5801ee5cf7 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -25,7 +25,6 @@ import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.id3.PrivFrame; -import com.google.android.exoplayer2.source.SampleQueue; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; import com.google.android.exoplayer2.upstream.DataSource; @@ -35,7 +34,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.TimestampAdjuster; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; -import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableList; import java.io.EOFException; import java.io.IOException; import java.io.InterruptedIOException; @@ -132,7 +131,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; Id3Decoder id3Decoder; ParsableByteArray scratchId3Data; boolean shouldSpliceIn; - ImmutableMap sampleQueueDiscardFromIndices = ImmutableMap.of(); if (previousChunk != null) { boolean isFollowingChunk = playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted; @@ -143,9 +141,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; || (mediaPlaylist.hasIndependentSegments && segmentStartTimeInPeriodUs >= previousChunk.endTimeUs); shouldSpliceIn = !canContinueWithoutSplice; - if (shouldSpliceIn) { - sampleQueueDiscardFromIndices = previousChunk.sampleQueueDiscardFromIndices; - } previousExtractor = isFollowingChunk && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber @@ -181,8 +176,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; previousExtractor, id3Decoder, scratchId3Data, - shouldSpliceIn, - sampleQueueDiscardFromIndices); + shouldSpliceIn); } public static final String PRIV_TIMESTAMP_FRAME_OWNER = @@ -203,6 +197,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** The url of the playlist from which this chunk was obtained. */ public final Uri playlistUrl; + /** Whether samples for this chunk should be spliced into existing samples. */ + public final boolean shouldSpliceIn; + @Nullable private final DataSource initDataSource; @Nullable private final DataSpec initDataSpec; @Nullable private final HlsMediaChunkExtractor previousExtractor; @@ -217,7 +214,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ParsableByteArray scratchId3Data; private final boolean mediaSegmentEncrypted; private final boolean initSegmentEncrypted; - private final boolean shouldSpliceIn; private @MonotonicNonNull HlsMediaChunkExtractor extractor; private @MonotonicNonNull HlsSampleStreamWrapper output; @@ -227,7 +223,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private boolean initDataLoadRequired; private volatile boolean loadCanceled; private boolean loadCompleted; - private ImmutableMap sampleQueueDiscardFromIndices; + private ImmutableList sampleQueueFirstSampleIndices; private HlsMediaChunk( HlsExtractorFactory extractorFactory, @@ -253,8 +249,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Nullable HlsMediaChunkExtractor previousExtractor, Id3Decoder id3Decoder, ParsableByteArray scratchId3Data, - boolean shouldSpliceIn, - ImmutableMap sampleQueueDiscardFromIndices) { + boolean shouldSpliceIn) { super( mediaDataSource, dataSpec, @@ -281,7 +276,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.id3Decoder = id3Decoder; this.scratchId3Data = scratchId3Data; this.shouldSpliceIn = shouldSpliceIn; - this.sampleQueueDiscardFromIndices = sampleQueueDiscardFromIndices; + sampleQueueFirstSampleIndices = ImmutableList.of(); uid = uidSource.getAndIncrement(); } @@ -289,35 +284,29 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * Initializes the chunk for loading. * * @param output The {@link HlsSampleStreamWrapper} that will receive the loaded samples. - * @param sampleQueues The {@link SampleQueue sampleQueues} with already loaded samples. + * @param sampleQueueWriteIndices The current write indices in the existing sample queues of the + * output. */ - public void init(HlsSampleStreamWrapper output, SampleQueue[] sampleQueues) { + public void init(HlsSampleStreamWrapper output, ImmutableList sampleQueueWriteIndices) { this.output = output; - if (shouldSpliceIn) { - for (SampleQueue sampleQueue : sampleQueues) { - sampleQueue.splice(); - } - // sampleQueueDiscardFromIndices already set to values of previous chunk in constructor. - } else { - ImmutableMap.Builder mapBuilder = ImmutableMap.builder(); - for (SampleQueue sampleQueue : sampleQueues) { - mapBuilder.put(sampleQueue, sampleQueue.getWriteIndex()); - } - sampleQueueDiscardFromIndices = mapBuilder.build(); - } + this.sampleQueueFirstSampleIndices = sampleQueueWriteIndices; } /** - * Returns the absolute index from which samples need to be discarded in the given {@link - * SampleQueue} when this media chunk is discarded. + * Returns the first sample index of this chunk in the specified sample queue in the output. * - * @param sampleQueue The {@link SampleQueue}. - * @return The absolute index from which samples need to be discarded. + *

          Must not be used if {@link #shouldSpliceIn} is true. + * + * @param sampleQueueIndex The index of the sample queue in the output. + * @return The first sample index of this chunk in the specified sample queue. */ - int getSampleQueueDiscardFromIndex(SampleQueue sampleQueue) { - // If the sample queue was created by this chunk or a later chunk, return 0 to discard the whole - // stream from the beginning. - return sampleQueueDiscardFromIndices.getOrDefault(sampleQueue, /* defaultValue= */ 0); + int getFirstSampleIndex(int sampleQueueIndex) { + Assertions.checkState(!shouldSpliceIn); + if (sampleQueueIndex >= sampleQueueFirstSampleIndices.size()) { + // The sample queue was created by this chunk or a later chunk. + return 0; + } + return sampleQueueFirstSampleIndices.get(sampleQueueIndex); } @Override diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 78c9e2796d..5f0aff46cc 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -61,6 +61,7 @@ import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.EOFException; import java.io.IOException; import java.util.ArrayList; @@ -873,9 +874,16 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; upstreamTrackFormat = chunk.trackFormat; pendingResetPositionUs = C.TIME_UNSET; mediaChunks.add(chunk); - chunk.init(/* output= */ this, sampleQueues); + ImmutableList.Builder 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(); + } } } @@ -884,7 +892,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int newQueueSize = C.LENGTH_UNSET; for (int i = preferredQueueSize; i < mediaChunks.size(); i++) { - if (!haveReadFromMediaChunkDiscardRange(i)) { + if (canDiscardUpstreamMediaChunksFromIndex(i)) { newQueueSize = i; break; } @@ -1102,23 +1110,32 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return true; } - private boolean haveReadFromMediaChunkDiscardRange(int mediaChunkIndex) { - HlsMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); - for (SampleQueue sampleQueue : sampleQueues) { - int discardFromIndex = mediaChunk.getSampleQueueDiscardFromIndex(sampleQueue); - if (sampleQueue.getReadIndex() > discardFromIndex) { - 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; } } - 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. + return false; + } + } + return true; } private HlsMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { HlsMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); - for (SampleQueue sampleQueue : sampleQueues) { - int discardFromIndex = firstRemovedChunk.getSampleQueueDiscardFromIndex(sampleQueue); - sampleQueue.discardUpstreamSamples(discardFromIndex); + for (int i = 0; i < sampleQueues.length; i++) { + int discardFromIndex = firstRemovedChunk.getFirstSampleIndex(/* sampleQueueIndex= */ i); + sampleQueues[i].discardUpstreamSamples(discardFromIndex); } return firstRemovedChunk; } From a3bbcf3395094bc47ed931d2afe9fe765426da36 Mon Sep 17 00:00:00 2001 From: insun Date: Tue, 30 Jun 2020 10:34:15 +0100 Subject: [PATCH 0568/1052] Add StyledPlayerView and StyledPlayerControlView into ui/ Moved ui2/ code and resources into ui/ PiperOrigin-RevId: 318984707 --- RELEASENOTES.md | 1 + constants.gradle | 1 + library/ui/build.gradle | 1 + .../ui/StyledPlayerControlView.java | 2210 +++++++++++++++++ .../StyledPlayerControlViewLayoutManager.java | 736 ++++++ .../exoplayer2/ui/StyledPlayerView.java | 1709 +++++++++++++ .../main/res/drawable-v21/exo_ripple_ffwd.xml | 26 + .../main/res/drawable-v21/exo_ripple_rew.xml | 26 + .../main/res/drawable/exo_ic_audiotrack.xml | 31 + .../ui/src/main/res/drawable/exo_ic_check.xml | 28 + .../main/res/drawable/exo_ic_chevron_left.xml | 24 + .../res/drawable/exo_ic_chevron_right.xml | 24 + .../drawable/exo_ic_default_album_image.xml | 29 + .../src/main/res/drawable/exo_ic_forward.xml | 31 + .../main/res/drawable/exo_ic_forward_30.xml | 29 + .../res/drawable/exo_ic_fullscreen_enter.xml | 25 + .../res/drawable/exo_ic_fullscreen_exit.xml | 25 + .../src/main/res/drawable/exo_ic_launch.xml | 25 + .../drawable/exo_ic_pause_circle_filled.xml | 25 + .../drawable/exo_ic_play_circle_filled.xml | 25 + .../drawable/exo_ic_replay_circle_filled.xml | 32 + .../src/main/res/drawable/exo_ic_rewind.xml | 32 + .../main/res/drawable/exo_ic_rewind_10.xml | 29 + .../src/main/res/drawable/exo_ic_settings.xml | 25 + .../main/res/drawable/exo_ic_skip_next.xml | 25 + .../res/drawable/exo_ic_skip_previous.xml | 25 + .../ui/src/main/res/drawable/exo_ic_speed.xml | 25 + .../main/res/drawable/exo_ic_subtitle_off.xml | 34 + .../main/res/drawable/exo_ic_subtitle_on.xml | 28 + .../ui/src/main/res/drawable/exo_progress.xml | 36 + .../main/res/drawable/exo_progress_thumb.xml | 26 + .../src/main/res/drawable/exo_ripple_ffwd.xml | 29 + .../src/main/res/drawable/exo_ripple_rew.xml | 29 + .../res/drawable/exo_title_bar_gradient.xml | 26 + .../main/res/font/roboto_medium_numbers.ttf | Bin 0 -> 3316 bytes ...exo_styled_embedded_transport_controls.xml | 28 + .../layout/exo_styled_player_control_view.xml | 176 ++ .../res/layout/exo_styled_player_view.xml | 18 + .../res/layout/exo_styled_settings_list.xml | 23 + .../layout/exo_styled_settings_list_item.xml | 55 + .../exo_styled_sub_settings_list_item.xml | 44 + library/ui/src/main/res/values/arrays.xml | 47 + library/ui/src/main/res/values/attrs.xml | 121 +- library/ui/src/main/res/values/colors.xml | 27 + library/ui/src/main/res/values/dimens.xml | 55 + library/ui/src/main/res/values/drawables.xml | 24 + library/ui/src/main/res/values/strings.xml | 59 +- library/ui/src/main/res/values/styles.xml | 138 + 48 files changed, 6229 insertions(+), 18 deletions(-) create mode 100644 library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java create mode 100644 library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java create mode 100644 library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java create mode 100644 library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml create mode 100644 library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_audiotrack.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_check.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_chevron_left.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_chevron_right.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_default_album_image.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_forward.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_forward_30.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_launch.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_rewind.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_rewind_10.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_settings.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_skip_next.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_skip_previous.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_speed.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml create mode 100644 library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml create mode 100644 library/ui/src/main/res/drawable/exo_progress.xml create mode 100644 library/ui/src/main/res/drawable/exo_progress_thumb.xml create mode 100644 library/ui/src/main/res/drawable/exo_ripple_ffwd.xml create mode 100644 library/ui/src/main/res/drawable/exo_ripple_rew.xml create mode 100644 library/ui/src/main/res/drawable/exo_title_bar_gradient.xml create mode 100644 library/ui/src/main/res/font/roboto_medium_numbers.ttf create mode 100644 library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml create mode 100644 library/ui/src/main/res/layout/exo_styled_player_control_view.xml create mode 100644 library/ui/src/main/res/layout/exo_styled_player_view.xml create mode 100644 library/ui/src/main/res/layout/exo_styled_settings_list.xml create mode 100644 library/ui/src/main/res/layout/exo_styled_settings_list_item.xml create mode 100644 library/ui/src/main/res/layout/exo_styled_sub_settings_list_item.xml create mode 100644 library/ui/src/main/res/values/arrays.xml create mode 100644 library/ui/src/main/res/values/colors.xml create mode 100644 library/ui/src/main/res/values/dimens.xml diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 63f8b2594d..4fe20d018d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -202,6 +202,7 @@ * Upgrade Truth dependency from 0.44 to 1.0. * Upgrade to JUnit 4.13-rc-2. * UI + * Add `StyledPlayerView` and `StyledPlayerControlView`. * Remove `SimpleExoPlayerView` and `PlaybackControlView`. * Remove deperecated `exo_simple_player_view.xml` and `exo_playback_control_view.xml` from resource. diff --git a/constants.gradle b/constants.gradle index c95315df6d..0b3362a18a 100644 --- a/constants.gradle +++ b/constants.gradle @@ -34,6 +34,7 @@ project.ext { androidxCollectionVersion = '1.1.0' androidxMediaVersion = '1.0.1' androidxMultidexVersion = '2.0.0' + androidxRecyclerViewVersion = '1.1.0' androidxTestCoreVersion = '1.2.0' androidxTestJUnitVersion = '1.1.1' androidxTestRunnerVersion = '1.2.0' diff --git a/library/ui/build.gradle b/library/ui/build.gradle index 2184443a5d..5b24cfbc62 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -20,6 +20,7 @@ dependencies { api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'androidx.recyclerview:recyclerview:' + androidxRecyclerViewVersion implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java new file mode 100644 index 0000000000..5da2445f7e --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -0,0 +1,2210 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ui; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.core.content.res.ResourcesCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackPreparer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.ParametersBuilder; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.RepeatModeUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Formatter; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A view for controlling {@link Player} instances. + * + *

          A StyledPlayerControlView can be customized by setting attributes (or calling corresponding + * methods), overriding drawables, overriding the view's layout file, or by specifying a custom view + * layout file. + * + *

          Attributes

          + * + * The following attributes can be set on a StyledPlayerControlView when used in a layout XML file: + * + *
            + *
          • {@code show_timeout} - The time between the last user interaction and the controls + * being automatically hidden, in milliseconds. Use zero if the controls should not + * automatically timeout. + *
              + *
            • Corresponding method: {@link #setShowTimeoutMs(int)} + *
            • Default: {@link #DEFAULT_SHOW_TIMEOUT_MS} + *
            + *
          • {@code show_rewind_button} - Whether the rewind button is shown. + *
              + *
            • Corresponding method: {@link #setShowRewindButton(boolean)} + *
            • Default: true + *
            + *
          • {@code show_fastforward_button} - Whether the fast forward button is shown. + *
              + *
            • Corresponding method: {@link #setShowFastForwardButton(boolean)} + *
            • Default: true + *
            + *
          • {@code show_previous_button} - Whether the previous button is shown. + *
              + *
            • Corresponding method: {@link #setShowPreviousButton(boolean)} + *
            • Default: true + *
            + *
          • {@code show_next_button} - Whether the next button is shown. + *
              + *
            • Corresponding method: {@link #setShowNextButton(boolean)} + *
            • Default: true + *
            + *
          • {@code rewind_increment} - The duration of the rewind applied when the user taps the + * rewind button, in milliseconds. Use zero to disable the rewind button. + *
              + *
            • Corresponding method: {@link #setControlDispatcher(ControlDispatcher)} + *
            • Default: {@link DefaultControlDispatcher#DEFAULT_REWIND_MS} + *
            + *
          • {@code fastforward_increment} - Like {@code rewind_increment}, but for fast forward. + *
              + *
            • Corresponding method: {@link #setControlDispatcher(ControlDispatcher)} + *
            • Default: {@link DefaultControlDispatcher#DEFAULT_FAST_FORWARD_MS} + *
            + *
          • {@code repeat_toggle_modes} - A flagged enumeration value specifying which repeat + * mode toggle options are enabled. Valid values are: {@code none}, {@code one}, {@code all}, + * or {@code one|all}. + *
              + *
            • Corresponding method: {@link #setRepeatToggleModes(int)} + *
            • Default: {@link #DEFAULT_REPEAT_TOGGLE_MODES} + *
            + *
          • {@code show_shuffle_button} - Whether the shuffle button is shown. + *
              + *
            • Corresponding method: {@link #setShowShuffleButton(boolean)} + *
            • Default: false + *
            + *
          • {@code disable_animation} - Whether animation is applied when hide and show + * controls. + *
              + *
            • Corresponding method: None + *
            • Default: false + *
            + *
          • {@code time_bar_min_update_interval} - Specifies the minimum interval between time + * bar position updates. + *
              + *
            • Corresponding method: {@link #setTimeBarMinUpdateInterval(int)} + *
            • Default: {@link #DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS} + *
            + *
          • {@code controller_layout_id} - Specifies the id of the layout to be inflated. See + * below for more details. + *
              + *
            • Corresponding method: None + *
            • Default: {@code R.layout.exo_styled_player_control_view} + *
            + *
          • All attributes that can be set on {@link DefaultTimeBar} can also be set on a + * StyledPlayerControlView, and will be propagated to the inflated {@link DefaultTimeBar} + * unless the layout is overridden to specify a custom {@code exo_progress} (see below). + *
          + * + *

          Overriding drawables

          + * + * The drawables used by StyledPlayerControlView (with its default layout file) can be overridden by + * drawables with the same names defined in your application. The drawables that can be overridden + * are: + * + *
            + *
          • {@code exo_styled_controls_play} - The play icon. + *
          • {@code exo_styled_controls_pause} - The pause icon. + *
          • {@code exo_styled_controls_rewind} - The background of rewind icon. + *
          • {@code exo_styled_controls_fastforward} - The background of fast forward icon. + *
          • {@code exo_styled_controls_previous} - The previous icon. + *
          • {@code exo_styled_controls_next} - The next icon. + *
          • {@code exo_styled_controls_repeat_off} - The repeat icon for {@link + * Player#REPEAT_MODE_OFF}. + *
          • {@code exo_styled_controls_repeat_one} - The repeat icon for {@link + * Player#REPEAT_MODE_ONE}. + *
          • {@code exo_styled_controls_repeat_all} - The repeat icon for {@link + * Player#REPEAT_MODE_ALL}. + *
          • {@code exo_styled_controls_shuffle_off} - The shuffle icon when shuffling is + * disabled. + *
          • {@code exo_styled_controls_shuffle_on} - The shuffle icon when shuffling is enabled. + *
          • {@code exo_styled_controls_vr} - The VR icon. + *
          + * + *

          Overriding the layout file

          + * + * To customize the layout of StyledPlayerControlView throughout your app, or just for certain + * configurations, you can define {@code exo_styled_player_control_view.xml} layout files in your + * application {@code res/layout*} directories. But, in this case, you need to be careful since the + * default animation implementation expects certain relative positions between children. See also Specifying a custom layout file. + * + *

          The layout files in your {@code res/layout*} will override the one provided by the ExoPlayer + * library, and will be inflated for use by StyledPlayerControlView. The view identifies and binds + * its children by looking for the following ids: + * + *

            + *
          • {@code exo_play} - The play button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_pause} - The pause button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_rew} - The rewind button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_rew_with_amount} - The rewind button with rewind amount. + *
              + *
            • Type: {@link TextView} + *
            • Note: StyledPlayerControlView will programmatically set the text with the rewind + * amount in seconds. Ignored if an {@code exo_rew} exists. Otherwise, it works as the + * rewind button. + *
            + *
          • {@code exo_ffwd} - The fast forward button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_ffwd_with_amount} - The fast forward button with fast forward amount. + *
              + *
            • Type: {@link TextView} + *
            • Note: StyledPlayerControlView will programmatically set the text with the fast + * forward amount in seconds. Ignored if an {@code exo_ffwd} exists. Otherwise, it works + * as the fast forward button. + *
            + *
          • {@code exo_prev} - The previous button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_next} - The next button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_repeat_toggle} - The repeat toggle button. + *
              + *
            • Type: {@link ImageView} + *
            • Note: StyledPlayerControlView will programmatically set the drawable on the repeat + * toggle button according to the player's current repeat mode. The drawables used are + * {@code exo_controls_repeat_off}, {@code exo_controls_repeat_one} and {@code + * exo_controls_repeat_all}. See the section above for information on overriding these + * drawables. + *
            + *
          • {@code exo_shuffle} - The shuffle button. + *
              + *
            • Type: {@link ImageView} + *
            • Note: StyledPlayerControlView will programmatically set the drawable on the shuffle + * button according to the player's current repeat mode. The drawables used are {@code + * exo_controls_shuffle_off} and {@code exo_controls_shuffle_on}. See the section above + * for information on overriding these drawables. + *
            + *
          • {@code exo_vr} - The VR mode button. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_position} - Text view displaying the current playback position. + *
              + *
            • Type: {@link TextView} + *
            + *
          • {@code exo_duration} - Text view displaying the current media duration. + *
              + *
            • Type: {@link TextView} + *
            + *
          • {@code exo_progress_placeholder} - A placeholder that's replaced with the inflated + * {@link DefaultTimeBar}. Ignored if an {@code exo_progress} view exists. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_progress} - Time bar that's updated during playback and allows seeking. + * {@link DefaultTimeBar} attributes set on the StyledPlayerControlView will not be + * automatically propagated through to this instance. If a view exists with this id, any + * {@code exo_progress_placeholder} view will be ignored. + *
              + *
            • Type: {@link TimeBar} + *
            + *
          + * + *

          All child views are optional and so can be omitted if not required, however where defined they + * must be of the expected type. + * + *

          Specifying a custom layout file

          + * + * Defining your own {@code exo_styled_player_control_view.xml} is useful to customize the layout of + * StyledPlayerControlView throughout your application. It's also possible to customize the layout + * for a single instance in a layout file. This is achieved by setting the {@code + * controller_layout_id} attribute on a StyledPlayerControlView. This will cause the specified + * layout to be inflated instead of {@code exo_styled_player_control_view.xml} for only the instance + * on which the attribute is set. + * + *

          You need to be careful when you set the {@code controller_layout_id}, because the default + * animation implementation expects certain relative positions between children. + */ +public class StyledPlayerControlView extends FrameLayout { + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.ui"); + } + + /** Listener to be notified about changes of the visibility of the UI control. */ + public interface VisibilityListener { + + /** + * Called when the visibility changes. + * + * @param visibility The new visibility. Either {@link View#VISIBLE} or {@link View#GONE}. + */ + void onVisibilityChange(int visibility); + } + + /** Listener to be notified when progress has been updated. */ + public interface ProgressUpdateListener { + + /** + * Called when progress needs to be updated. + * + * @param position The current position. + * @param bufferedPosition The current buffered position. + */ + void onProgressUpdate(long position, long bufferedPosition); + } + + /** + * Listener to be invoked to inform the fullscreen mode is changed. Application should handle the + * fullscreen mode accordingly. + */ + public interface OnFullScreenModeChangedListener { + /** + * Called to indicate a fullscreen mode change. + * + * @param isFullScreen {@code true} if the video rendering surface should be fullscreen {@code + * false} otherwise. + */ + void onFullScreenModeChanged(boolean isFullScreen); + } + + /** The default show timeout, in milliseconds. */ + public static final int DEFAULT_SHOW_TIMEOUT_MS = 5_000; + /** The default repeat toggle modes. */ + public static final @RepeatModeUtil.RepeatToggleModes int DEFAULT_REPEAT_TOGGLE_MODES = + RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE; + /** The default minimum interval between time bar position updates. */ + public static final int DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS = 200; + /** The maximum number of windows that can be shown in a multi-window time bar. */ + public static final int MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR = 100; + /** The maximum interval between time bar position updates. */ + private static final int MAX_UPDATE_INTERVAL_MS = 1_000; + + private static final int SETTINGS_PLAYBACK_SPEED_POSITION = 0; + private static final int SETTINGS_AUDIO_TRACK_SELECTION_POSITION = 1; + private static final int UNDEFINED_POSITION = -1; + + private final ComponentListener componentListener; + private final CopyOnWriteArrayList visibilityListeners; + @Nullable private final View previousButton; + @Nullable private final View nextButton; + @Nullable private final View playPauseButton; + @Nullable private final View fastForwardButton; + @Nullable private final View rewindButton; + @Nullable private final TextView fastForwardButtonTextView; + @Nullable private final TextView rewindButtonTextView; + @Nullable private final ImageView repeatToggleButton; + @Nullable private final ImageView shuffleButton; + @Nullable private final View vrButton; + @Nullable private final TextView durationView; + @Nullable private final TextView positionView; + @Nullable private final TimeBar timeBar; + private final StringBuilder formatBuilder; + private final Formatter formatter; + private final Timeline.Period period; + private final Timeline.Window window; + private final Runnable updateProgressAction; + + private final Drawable repeatOffButtonDrawable; + private final Drawable repeatOneButtonDrawable; + private final Drawable repeatAllButtonDrawable; + private final String repeatOffButtonContentDescription; + private final String repeatOneButtonContentDescription; + private final String repeatAllButtonContentDescription; + private final Drawable shuffleOnButtonDrawable; + private final Drawable shuffleOffButtonDrawable; + private final float buttonAlphaEnabled; + private final float buttonAlphaDisabled; + private final String shuffleOnContentDescription; + private final String shuffleOffContentDescription; + private final Drawable fullScreenExitDrawable; + private final Drawable fullScreenEnterDrawable; + private final String fullScreenExitContentDescription; + private final String fullScreenEnterContentDescription; + + @Nullable private Player player; + private com.google.android.exoplayer2.ControlDispatcher controlDispatcher; + @Nullable private ProgressUpdateListener progressUpdateListener; + @Nullable private PlaybackPreparer playbackPreparer; + + @Nullable private OnFullScreenModeChangedListener onFullScreenModeChangedListener; + private boolean isFullScreen; + private boolean isAttachedToWindow; + private boolean showMultiWindowTimeBar; + private boolean multiWindowTimeBar; + private boolean scrubbing; + private int showTimeoutMs; + private int timeBarMinUpdateIntervalMs; + private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes; + private boolean showRewindButton; + private boolean showFastForwardButton; + private boolean showPreviousButton; + private boolean showNextButton; + private boolean showShuffleButton; + private boolean showSubtitleButton; + private long[] adGroupTimesMs; + private boolean[] playedAdGroups; + private long[] extraAdGroupTimesMs; + private boolean[] extraPlayedAdGroups; + private long currentWindowOffset; + private long rewindMs; + private long fastForwardMs; + + private StyledPlayerControlViewLayoutManager controlViewLayoutManager; + private Resources resources; + + // Relating to Settings List View + private int selectedMainSettingsPosition; + private RecyclerView settingsView; + private SettingsAdapter settingsAdapter; + private SubSettingsAdapter subSettingsAdapter; + private PopupWindow settingsWindow; + private List playbackSpeedTextList; + private List playbackSpeedMultBy100List; + private int customPlaybackSpeedIndex; + private int selectedPlaybackSpeedIndex; + private boolean needToHideBars; + private int settingsWindowMargin; + + @Nullable private DefaultTrackSelector trackSelector; + private TrackSelectionAdapter textTrackSelectionAdapter; + private TrackSelectionAdapter audioTrackSelectionAdapter; + // TODO(insun): Add setTrackNameProvider to use customized track name provider. + private TrackNameProvider trackNameProvider; + + // Relating to Bottom Bar Right View + @Nullable private View subtitleButton; + @Nullable private ImageView fullScreenButton; + @Nullable private View settingsButton; + + public StyledPlayerControlView(Context context) { + this(context, /* attrs= */ null); + } + + public StyledPlayerControlView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, /* defStyleAttr= */ 0); + } + + public StyledPlayerControlView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, attrs); + } + + @SuppressWarnings({ + "nullness:argument.type.incompatible", + "nullness:method.invocation.invalid", + "nullness:methodref.receiver.bound.invalid" + }) + public StyledPlayerControlView( + Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + @Nullable AttributeSet playbackAttrs) { + super(context, attrs, defStyleAttr); + int controllerLayoutId = R.layout.exo_styled_player_control_view; + rewindMs = DefaultControlDispatcher.DEFAULT_REWIND_MS; + fastForwardMs = DefaultControlDispatcher.DEFAULT_FAST_FORWARD_MS; + showTimeoutMs = DEFAULT_SHOW_TIMEOUT_MS; + repeatToggleModes = DEFAULT_REPEAT_TOGGLE_MODES; + timeBarMinUpdateIntervalMs = DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS; + showRewindButton = true; + showFastForwardButton = true; + showPreviousButton = true; + showNextButton = true; + showShuffleButton = false; + showSubtitleButton = false; + boolean disableAnimation = false; + + boolean showVrButton = false; + + if (playbackAttrs != null) { + TypedArray a = + context + .getTheme() + .obtainStyledAttributes(playbackAttrs, R.styleable.StyledPlayerControlView, 0, 0); + try { + rewindMs = a.getInt(R.styleable.StyledPlayerControlView_rewind_increment, (int) rewindMs); + fastForwardMs = + a.getInt( + R.styleable.StyledPlayerControlView_fastforward_increment, (int) fastForwardMs); + controllerLayoutId = + a.getResourceId( + R.styleable.StyledPlayerControlView_controller_layout_id, controllerLayoutId); + showTimeoutMs = a.getInt(R.styleable.StyledPlayerControlView_show_timeout, showTimeoutMs); + repeatToggleModes = getRepeatToggleModes(a, repeatToggleModes); + showRewindButton = + a.getBoolean(R.styleable.StyledPlayerControlView_show_rewind_button, showRewindButton); + showFastForwardButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_fastforward_button, showFastForwardButton); + showPreviousButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_previous_button, showPreviousButton); + showNextButton = + a.getBoolean(R.styleable.StyledPlayerControlView_show_next_button, showNextButton); + showShuffleButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_shuffle_button, showShuffleButton); + showSubtitleButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_subtitle_button, showSubtitleButton); + showVrButton = + a.getBoolean(R.styleable.StyledPlayerControlView_show_vr_button, showVrButton); + setTimeBarMinUpdateInterval( + a.getInt( + R.styleable.StyledPlayerControlView_time_bar_min_update_interval, + timeBarMinUpdateIntervalMs)); + disableAnimation = + a.getBoolean(R.styleable.StyledPlayerControlView_disable_animation, disableAnimation); + } finally { + a.recycle(); + } + } + + controlViewLayoutManager = new StyledPlayerControlViewLayoutManager(); + controlViewLayoutManager.setDisableAnimation(disableAnimation); + visibilityListeners = new CopyOnWriteArrayList<>(); + period = new Timeline.Period(); + window = new Timeline.Window(); + formatBuilder = new StringBuilder(); + formatter = new Formatter(formatBuilder, Locale.getDefault()); + adGroupTimesMs = new long[0]; + playedAdGroups = new boolean[0]; + extraAdGroupTimesMs = new long[0]; + extraPlayedAdGroups = new boolean[0]; + componentListener = new ComponentListener(); + controlDispatcher = + new com.google.android.exoplayer2.DefaultControlDispatcher(fastForwardMs, rewindMs); + updateProgressAction = this::updateProgress; + + LayoutInflater.from(context).inflate(controllerLayoutId, /* root= */ this); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + + // Relating to Bottom Bar Left View + durationView = findViewById(R.id.exo_duration); + positionView = findViewById(R.id.exo_position); + + // Relating to Bottom Bar Right View + subtitleButton = findViewById(R.id.exo_subtitle); + if (subtitleButton != null) { + subtitleButton.setOnClickListener(componentListener); + subtitleButton.setVisibility(showSubtitleButton ? VISIBLE : GONE); + } + fullScreenButton = findViewById(R.id.exo_fullscreen); + if (fullScreenButton != null) { + fullScreenButton.setOnClickListener(fullScreenModeChangedListener); + } + settingsButton = findViewById(R.id.exo_settings); + if (settingsButton != null) { + settingsButton.setOnClickListener(componentListener); + } + + TimeBar customTimeBar = findViewById(R.id.exo_progress); + View timeBarPlaceholder = findViewById(R.id.exo_progress_placeholder); + if (customTimeBar != null) { + timeBar = customTimeBar; + } else if (timeBarPlaceholder != null) { + // Propagate attrs as timebarAttrs so that DefaultTimeBar's custom attributes are transferred, + // but standard attributes (e.g. background) are not. + DefaultTimeBar defaultTimeBar = new DefaultTimeBar(context, null, 0, playbackAttrs); + defaultTimeBar.setId(R.id.exo_progress); + defaultTimeBar.setLayoutParams(timeBarPlaceholder.getLayoutParams()); + ViewGroup parent = ((ViewGroup) timeBarPlaceholder.getParent()); + int timeBarIndex = parent.indexOfChild(timeBarPlaceholder); + parent.removeView(timeBarPlaceholder); + parent.addView(defaultTimeBar, timeBarIndex); + timeBar = defaultTimeBar; + } else { + timeBar = null; + } + + if (timeBar != null) { + timeBar.addListener(componentListener); + } + playPauseButton = findViewById(R.id.exo_play_pause); + if (playPauseButton != null) { + playPauseButton.setOnClickListener(componentListener); + } + previousButton = findViewById(R.id.exo_prev); + if (previousButton != null) { + previousButton.setOnClickListener(componentListener); + } + nextButton = findViewById(R.id.exo_next); + if (nextButton != null) { + nextButton.setOnClickListener(componentListener); + } + Typeface typeface = ResourcesCompat.getFont(context, R.font.roboto_medium_numbers); + View rewButton = findViewById(R.id.exo_rew); + rewindButtonTextView = rewButton == null ? findViewById(R.id.exo_rew_with_amount) : null; + if (rewindButtonTextView != null) { + rewindButtonTextView.setTypeface(typeface); + } + rewindButton = rewButton == null ? rewindButtonTextView : rewButton; + if (rewindButton != null) { + rewindButton.setOnClickListener(componentListener); + } + View ffwdButton = findViewById(R.id.exo_ffwd); + fastForwardButtonTextView = ffwdButton == null ? findViewById(R.id.exo_ffwd_with_amount) : null; + if (fastForwardButtonTextView != null) { + fastForwardButtonTextView.setTypeface(typeface); + } + fastForwardButton = ffwdButton == null ? fastForwardButtonTextView : ffwdButton; + if (fastForwardButton != null) { + fastForwardButton.setOnClickListener(componentListener); + } + repeatToggleButton = findViewById(R.id.exo_repeat_toggle); + if (repeatToggleButton != null) { + repeatToggleButton.setOnClickListener(componentListener); + } + shuffleButton = findViewById(R.id.exo_shuffle); + if (shuffleButton != null) { + shuffleButton.setOnClickListener(componentListener); + } + + resources = context.getResources(); + + buttonAlphaEnabled = + (float) resources.getInteger(R.integer.exo_media_button_opacity_percentage_enabled) / 100; + buttonAlphaDisabled = + (float) resources.getInteger(R.integer.exo_media_button_opacity_percentage_disabled) / 100; + + vrButton = findViewById(R.id.exo_vr); + if (vrButton != null) { + setShowVrButton(showVrButton); + } + + // Related to Settings List View + List settingsMainTextsList = + Arrays.asList(resources.getStringArray(R.array.exo_settings_main_texts)); + TypedArray settingsIconTypedArray = resources.obtainTypedArray(R.array.exo_settings_icon_ids); + playbackSpeedTextList = + new ArrayList<>(Arrays.asList(resources.getStringArray(R.array.exo_playback_speeds))); + String normalSpeed = resources.getString(R.string.exo_controls_playback_speed_normal); + selectedPlaybackSpeedIndex = playbackSpeedTextList.indexOf(normalSpeed); + + playbackSpeedMultBy100List = new ArrayList(); + int[] speeds = resources.getIntArray(R.array.exo_speed_multiplied_by_100); + for (int speed : speeds) { + playbackSpeedMultBy100List.add(speed); + } + customPlaybackSpeedIndex = UNDEFINED_POSITION; + settingsWindowMargin = resources.getDimensionPixelSize(R.dimen.exo_settings_offset); + + settingsAdapter = new SettingsAdapter(settingsMainTextsList, settingsIconTypedArray); + subSettingsAdapter = new SubSettingsAdapter(); + subSettingsAdapter.setCheckPosition(UNDEFINED_POSITION); + settingsView = + (RecyclerView) + LayoutInflater.from(context).inflate(R.layout.exo_styled_settings_list, null); + settingsView.setAdapter(settingsAdapter); + settingsView.setLayoutManager(new LinearLayoutManager(getContext())); + settingsWindow = + new PopupWindow(settingsView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, true); + settingsWindow.setOnDismissListener(componentListener); + needToHideBars = true; + + trackNameProvider = new DefaultTrackNameProvider(getResources()); + textTrackSelectionAdapter = new TextTrackSelectionAdapter(); + audioTrackSelectionAdapter = new AudioTrackSelectionAdapter(); + + fullScreenExitDrawable = resources.getDrawable(R.drawable.exo_styled_controls_fullscreen_exit); + fullScreenEnterDrawable = + resources.getDrawable(R.drawable.exo_styled_controls_fullscreen_enter); + repeatOffButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_repeat_off); + repeatOneButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_repeat_one); + repeatAllButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_repeat_all); + shuffleOnButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_shuffle_on); + shuffleOffButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_shuffle_off); + fullScreenExitContentDescription = + resources.getString(R.string.exo_controls_fullscreen_exit_description); + fullScreenEnterContentDescription = + resources.getString(R.string.exo_controls_fullscreen_enter_description); + repeatOffButtonContentDescription = + resources.getString(R.string.exo_controls_repeat_off_description); + repeatOneButtonContentDescription = + resources.getString(R.string.exo_controls_repeat_one_description); + repeatAllButtonContentDescription = + resources.getString(R.string.exo_controls_repeat_all_description); + shuffleOnContentDescription = resources.getString(R.string.exo_controls_shuffle_on_description); + shuffleOffContentDescription = + resources.getString(R.string.exo_controls_shuffle_off_description); + + addOnLayoutChangeListener( + new OnLayoutChangeListener() { + @Override + public void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + int width = right - left; + int height = bottom - top; + int oldWidth = oldRight - oldLeft; + int oldHeight = oldBottom - oldTop; + + if ((width != oldWidth || height != oldHeight) && settingsWindow.isShowing()) { + updateSettingsWindowSize(); + + int xoff = getWidth() - settingsWindow.getWidth() - settingsWindowMargin; + int yoff = -settingsWindow.getHeight() - settingsWindowMargin; + + settingsWindow.update(v, xoff, yoff, -1, -1); + } + } + }); + } + + @SuppressWarnings("ResourceType") + private static @RepeatModeUtil.RepeatToggleModes int getRepeatToggleModes( + TypedArray a, @RepeatModeUtil.RepeatToggleModes int repeatToggleModes) { + return a.getInt(R.styleable.StyledPlayerControlView_repeat_toggle_modes, repeatToggleModes); + } + + /** + * Returns the {@link Player} currently being controlled by this view, or null if no player is + * set. + */ + @Nullable + public Player getPlayer() { + return player; + } + + /** + * Sets the {@link Player} to control. + * + * @param player The {@link Player} to control, or {@code null} to detach the current player. Only + * players which are accessed on the main thread are supported ({@code + * player.getApplicationLooper() == Looper.getMainLooper()}). + */ + public void setPlayer(@Nullable Player player) { + Assertions.checkState(Looper.myLooper() == Looper.getMainLooper()); + Assertions.checkArgument( + player == null || player.getApplicationLooper() == Looper.getMainLooper()); + if (this.player == player) { + return; + } + if (this.player != null) { + this.player.removeListener(componentListener); + } + this.player = player; + if (player != null) { + player.addListener(componentListener); + } + if (player != null && player.getTrackSelector() instanceof DefaultTrackSelector) { + this.trackSelector = (DefaultTrackSelector) player.getTrackSelector(); + } else { + this.trackSelector = null; + } + updateAll(); + updateSettingsPlaybackSpeedLists(); + } + + /** + * Sets whether the time bar should show all windows, as opposed to just the current one. If the + * timeline has a period with unknown duration or more than {@link + * #MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR} windows the time bar will fall back to showing a single + * window. + * + * @param showMultiWindowTimeBar Whether the time bar should show all windows. + */ + public void setShowMultiWindowTimeBar(boolean showMultiWindowTimeBar) { + this.showMultiWindowTimeBar = showMultiWindowTimeBar; + updateTimeline(); + } + + /** + * Sets the millisecond positions of extra ad markers relative to the start of the window (or + * timeline, if in multi-window mode) and whether each extra ad has been played or not. The + * markers are shown in addition to any ad markers for ads in the player's timeline. + * + * @param extraAdGroupTimesMs The millisecond timestamps of the extra ad markers to show, or + * {@code null} to show no extra ad markers. + * @param extraPlayedAdGroups Whether each ad has been played. Must be the same length as {@code + * extraAdGroupTimesMs}, or {@code null} if {@code extraAdGroupTimesMs} is {@code null}. + */ + public void setExtraAdGroupMarkers( + @Nullable long[] extraAdGroupTimesMs, @Nullable boolean[] extraPlayedAdGroups) { + if (extraAdGroupTimesMs == null) { + this.extraAdGroupTimesMs = new long[0]; + this.extraPlayedAdGroups = new boolean[0]; + } else { + extraPlayedAdGroups = checkNotNull(extraPlayedAdGroups); + Assertions.checkArgument(extraAdGroupTimesMs.length == extraPlayedAdGroups.length); + this.extraAdGroupTimesMs = extraAdGroupTimesMs; + this.extraPlayedAdGroups = extraPlayedAdGroups; + } + updateTimeline(); + } + + /** + * Adds a {@link VisibilityListener}. + * + * @param listener The listener to be notified about visibility changes. + */ + public void addVisibilityListener(VisibilityListener listener) { + visibilityListeners.add(listener); + } + + /** + * Removes a {@link VisibilityListener}. + * + * @param listener The listener to be removed. + */ + public void removeVisibilityListener(VisibilityListener listener) { + visibilityListeners.remove(listener); + } + + /** + * Sets the {@link ProgressUpdateListener}. + * + * @param listener The listener to be notified about when progress is updated. + */ + public void setProgressUpdateListener(@Nullable ProgressUpdateListener listener) { + this.progressUpdateListener = listener; + } + + /** + * Sets the {@link PlaybackPreparer}. + * + * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback + * preparer. + */ + public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { + this.playbackPreparer = playbackPreparer; + } + + /** + * Sets the {@link com.google.android.exoplayer2.ControlDispatcher}. + * + * @param controlDispatcher The {@link com.google.android.exoplayer2.ControlDispatcher}. + */ + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + if (this.controlDispatcher != controlDispatcher) { + this.controlDispatcher = controlDispatcher; + updateNavigation(); + } + } + + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + this.showRewindButton = showRewindButton; + updateNavigation(); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + this.showFastForwardButton = showFastForwardButton; + updateNavigation(); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + this.showPreviousButton = showPreviousButton; + updateNavigation(); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + this.showNextButton = showNextButton; + updateNavigation(); + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setRewindIncrementMs(int rewindMs) { + if (controlDispatcher instanceof DefaultControlDispatcher) { + ((DefaultControlDispatcher) controlDispatcher).setRewindIncrementMs(rewindMs); + updateNavigation(); + } + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setFastForwardIncrementMs(int fastForwardMs) { + if (controlDispatcher instanceof DefaultControlDispatcher) { + ((DefaultControlDispatcher) controlDispatcher).setFastForwardIncrementMs(fastForwardMs); + updateNavigation(); + } + } + + /** + * Returns the playback controls timeout. The playback controls are automatically hidden after + * this duration of time has elapsed without user input. + * + * @return The duration in milliseconds. A non-positive value indicates that the controls will + * remain visible indefinitely. + */ + public int getShowTimeoutMs() { + return showTimeoutMs; + } + + /** + * Sets the playback controls timeout. The playback controls are automatically hidden after this + * duration of time has elapsed without user input. + * + * @param showTimeoutMs The duration in milliseconds. A non-positive value will cause the controls + * to remain visible indefinitely. + */ + public void setShowTimeoutMs(int showTimeoutMs) { + this.showTimeoutMs = showTimeoutMs; + if (isFullyVisible()) { + controlViewLayoutManager.resetHideCallbacks(); + } + } + + /** + * Returns which repeat toggle modes are enabled. + * + * @return The currently enabled {@link RepeatModeUtil.RepeatToggleModes}. + */ + public @RepeatModeUtil.RepeatToggleModes int getRepeatToggleModes() { + return repeatToggleModes; + } + + /** + * Sets which repeat toggle modes are enabled. + * + * @param repeatToggleModes A set of {@link RepeatModeUtil.RepeatToggleModes}. + */ + public void setRepeatToggleModes(@RepeatModeUtil.RepeatToggleModes int repeatToggleModes) { + this.repeatToggleModes = repeatToggleModes; + if (player != null) { + @Player.RepeatMode int currentMode = player.getRepeatMode(); + if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE + && currentMode != Player.REPEAT_MODE_OFF) { + controlDispatcher.dispatchSetRepeatMode(player, Player.REPEAT_MODE_OFF); + } else if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE + && currentMode == Player.REPEAT_MODE_ALL) { + controlDispatcher.dispatchSetRepeatMode(player, Player.REPEAT_MODE_ONE); + } else if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL + && currentMode == Player.REPEAT_MODE_ONE) { + controlDispatcher.dispatchSetRepeatMode(player, Player.REPEAT_MODE_ALL); + } + } + updateRepeatModeButton(); + } + + /** Returns whether the shuffle button is shown. */ + public boolean getShowShuffleButton() { + return showShuffleButton; + } + + /** + * Sets whether the shuffle button is shown. + * + * @param showShuffleButton Whether the shuffle button is shown. + */ + public void setShowShuffleButton(boolean showShuffleButton) { + this.showShuffleButton = showShuffleButton; + updateShuffleButton(); + } + + /** Returns whether the subtitle button is shown. */ + public boolean getShowSubtitleButton() { + return showSubtitleButton; + } + + /** + * Sets whether the subtitle button is shown. + * + * @param showSubtitleButton Whether the subtitle button is shown. + */ + public void setShowSubtitleButton(boolean showSubtitleButton) { + this.showSubtitleButton = showSubtitleButton; + if (subtitleButton != null) { + subtitleButton.setVisibility(showSubtitleButton ? VISIBLE : GONE); + } + } + + /** Returns whether the VR button is shown. */ + public boolean getShowVrButton() { + return vrButton != null && vrButton.getVisibility() == VISIBLE; + } + + /** + * Sets whether the VR button is shown. + * + * @param showVrButton Whether the VR button is shown. + */ + public void setShowVrButton(boolean showVrButton) { + if (vrButton != null) { + updateButton(showVrButton, vrButton.hasOnClickListeners(), vrButton); + } + } + + /** + * Sets listener for the VR button. + * + * @param onClickListener Listener for the VR button, or null to clear the listener. + */ + public void setVrButtonListener(@Nullable OnClickListener onClickListener) { + if (vrButton != null) { + vrButton.setOnClickListener(onClickListener); + updateButton(getShowVrButton(), onClickListener != null, vrButton); + } + } + + /** + * Sets the minimum interval between time bar position updates. + * + *

          Note that smaller intervals, e.g. 33ms, will result in a smooth movement but will use more + * CPU resources while the time bar is visible, whereas larger intervals, e.g. 200ms, will result + * in a step-wise update with less CPU usage. + * + * @param minUpdateIntervalMs The minimum interval between time bar position updates, in + * milliseconds. + */ + public void setTimeBarMinUpdateInterval(int minUpdateIntervalMs) { + // Do not accept values below 16ms (60fps) and larger than the maximum update interval. + timeBarMinUpdateIntervalMs = + Util.constrainValue(minUpdateIntervalMs, 16, MAX_UPDATE_INTERVAL_MS); + } + + /** + * Sets a listener to be called when the fullscreen mode should be changed. A non-null listener + * needs to be set in order to display the fullscreen button. + * + * @param listener The listener to be called. A value of null removes any existing + * listener and hides the fullscreen button. + */ + public void setOnFullScreenModeChangedListener( + @Nullable OnFullScreenModeChangedListener listener) { + if (fullScreenButton == null) { + return; + } + + onFullScreenModeChangedListener = listener; + if (onFullScreenModeChangedListener == null) { + fullScreenButton.setVisibility(GONE); + } else { + fullScreenButton.setVisibility(VISIBLE); + } + } + + /** + * Shows the playback controls. If {@link #getShowTimeoutMs()} is positive then the controls will + * be automatically hidden after this duration of time has elapsed without user input. + */ + public void show() { + controlViewLayoutManager.show(); + } + + /** Hides the controller. */ + public void hide() { + controlViewLayoutManager.hide(); + } + + /** Returns whether the controller is fully visible, which means all UI controls are visible. */ + public boolean isFullyVisible() { + return controlViewLayoutManager.isFullyVisible(); + } + + /** Returns whether the controller is currently visible. */ + public boolean isVisible() { + return getVisibility() == VISIBLE; + } + + /* package */ void notifyOnVisibilityChange() { + for (VisibilityListener visibilityListener : visibilityListeners) { + visibilityListener.onVisibilityChange(getVisibility()); + } + } + + /* package */ void updateAll() { + updatePlayPauseButton(); + updateNavigation(); + updateRepeatModeButton(); + updateShuffleButton(); + updateTrackLists(); + updateTimeline(); + } + + private void updatePlayPauseButton() { + if (!isVisible() || !isAttachedToWindow) { + return; + } + if (playPauseButton != null) { + if (player != null && player.isPlaying()) { + ((ImageView) playPauseButton) + .setImageDrawable(resources.getDrawable(R.drawable.exo_styled_controls_pause)); + playPauseButton.setContentDescription( + resources.getString(R.string.exo_controls_pause_description)); + } else { + ((ImageView) playPauseButton) + .setImageDrawable(resources.getDrawable(R.drawable.exo_styled_controls_play)); + playPauseButton.setContentDescription( + resources.getString(R.string.exo_controls_play_description)); + } + } + } + + private void updateNavigation() { + if (!isVisible() || !isAttachedToWindow) { + return; + } + + @Nullable Player player = this.player; + boolean enableSeeking = false; + boolean enablePrevious = false; + boolean enableRewind = false; + boolean enableFastForward = false; + boolean enableNext = false; + if (player != null) { + Timeline timeline = player.getCurrentTimeline(); + if (!timeline.isEmpty() && !player.isPlayingAd()) { + timeline.getWindow(player.getCurrentWindowIndex(), window); + boolean isSeekable = window.isSeekable; + enableSeeking = isSeekable; + enablePrevious = isSeekable || !window.isDynamic || player.hasPrevious(); + enableRewind = isSeekable && controlDispatcher.isRewindEnabled(); + enableFastForward = isSeekable && controlDispatcher.isFastForwardEnabled(); + enableNext = window.isDynamic || player.hasNext(); + } + } + + if (enableRewind) { + updateRewindButton(); + } + if (enableFastForward) { + updateFastForwardButton(); + } + + updateButton(showPreviousButton, enablePrevious, previousButton); + updateButton(showRewindButton, enableRewind, rewindButton); + updateButton(showFastForwardButton, enableFastForward, fastForwardButton); + updateButton(showNextButton, enableNext, nextButton); + if (timeBar != null) { + timeBar.setEnabled(enableSeeking); + } + } + + private void updateRewindButton() { + if (controlDispatcher instanceof DefaultControlDispatcher) { + rewindMs = ((DefaultControlDispatcher) controlDispatcher).getRewindIncrementMs(); + } + long rewindSec = rewindMs / 1_000; + if (rewindButtonTextView != null) { + rewindButtonTextView.setText(String.valueOf(rewindSec)); + } + if (rewindButton != null) { + rewindButton.setContentDescription( + resources.getString(R.string.exo_controls_rewind_desc_holder, rewindSec)); + } + } + + private void updateFastForwardButton() { + if (controlDispatcher instanceof DefaultControlDispatcher) { + fastForwardMs = ((DefaultControlDispatcher) controlDispatcher).getFastForwardIncrementMs(); + } + long fastForwardSec = fastForwardMs / 1_000; + if (fastForwardButtonTextView != null) { + fastForwardButtonTextView.setText(String.valueOf(fastForwardSec)); + } + if (fastForwardButton != null) { + fastForwardButton.setContentDescription( + resources.getString(R.string.exo_controls_ffwd_desc_holder, fastForwardSec)); + } + } + + private void updateRepeatModeButton() { + if (!isVisible() || !isAttachedToWindow || repeatToggleButton == null) { + return; + } + + if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE) { + updateButton(/* visible= */ false, /* enabled= */ false, repeatToggleButton); + return; + } + + @Nullable Player player = this.player; + if (player == null) { + updateButton(/* visible= */ true, /* enabled= */ false, repeatToggleButton); + repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); + repeatToggleButton.setContentDescription(repeatOffButtonContentDescription); + return; + } + + updateButton(/* visible= */ true, /* enabled= */ true, repeatToggleButton); + switch (player.getRepeatMode()) { + case Player.REPEAT_MODE_OFF: + repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); + repeatToggleButton.setContentDescription(repeatOffButtonContentDescription); + break; + case Player.REPEAT_MODE_ONE: + repeatToggleButton.setImageDrawable(repeatOneButtonDrawable); + repeatToggleButton.setContentDescription(repeatOneButtonContentDescription); + break; + case Player.REPEAT_MODE_ALL: + repeatToggleButton.setImageDrawable(repeatAllButtonDrawable); + repeatToggleButton.setContentDescription(repeatAllButtonContentDescription); + break; + default: + // Never happens. + } + } + + private void updateShuffleButton() { + if (!isVisible() || !isAttachedToWindow || shuffleButton == null) { + return; + } + + @Nullable Player player = this.player; + if (!showShuffleButton) { + updateButton(/* visible= */ false, /* enabled= */ false, shuffleButton); + } else if (player == null) { + updateButton(/* visible= */ true, /* enabled= */ false, shuffleButton); + shuffleButton.setImageDrawable(shuffleOffButtonDrawable); + shuffleButton.setContentDescription(shuffleOffContentDescription); + } else { + updateButton(/* visible= */ true, /* enabled= */ true, shuffleButton); + shuffleButton.setImageDrawable( + player.getShuffleModeEnabled() ? shuffleOnButtonDrawable : shuffleOffButtonDrawable); + shuffleButton.setContentDescription( + player.getShuffleModeEnabled() + ? shuffleOnContentDescription + : shuffleOffContentDescription); + } + } + + private void updateTrackLists() { + initTrackSelectionAdapter(); + updateButton(showSubtitleButton, textTrackSelectionAdapter.getItemCount() > 0, subtitleButton); + } + + private void initTrackSelectionAdapter() { + textTrackSelectionAdapter.clear(); + audioTrackSelectionAdapter.clear(); + if (player == null || trackSelector == null) { + return; + } + DefaultTrackSelector trackSelector = this.trackSelector; + @Nullable MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { + return; + } + List textTracks = new ArrayList<>(); + List audioTracks = new ArrayList<>(); + List textRendererIndices = new ArrayList<>(); + List audioRendererIndices = new ArrayList<>(); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + if (mappedTrackInfo.getRendererType(rendererIndex) == C.TRACK_TYPE_TEXT + && showSubtitleButton) { + // Get TrackSelection at the corresponding renderer index. + gatherTrackInfosForAdapter(mappedTrackInfo, rendererIndex, textTracks); + textRendererIndices.add(rendererIndex); + } else if (mappedTrackInfo.getRendererType(rendererIndex) == C.TRACK_TYPE_AUDIO) { + gatherTrackInfosForAdapter(mappedTrackInfo, rendererIndex, audioTracks); + audioRendererIndices.add(rendererIndex); + } + } + textTrackSelectionAdapter.init(textRendererIndices, textTracks, mappedTrackInfo); + audioTrackSelectionAdapter.init(audioRendererIndices, audioTracks, mappedTrackInfo); + } + + private void gatherTrackInfosForAdapter( + MappedTrackInfo mappedTrackInfo, int rendererIndex, List tracks) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex); + + TrackSelectionArray trackSelections = checkNotNull(player).getCurrentTrackSelections(); + @Nullable TrackSelection trackSelection = trackSelections.get(rendererIndex); + + for (int groupIndex = 0; groupIndex < trackGroupArray.length; groupIndex++) { + TrackGroup trackGroup = trackGroupArray.get(groupIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + Format format = trackGroup.getFormat(trackIndex); + if (mappedTrackInfo.getTrackSupport(rendererIndex, groupIndex, trackIndex) + == RendererCapabilities.FORMAT_HANDLED) { + boolean trackIsSelected = + trackSelection != null && trackSelection.indexOf(format) != C.INDEX_UNSET; + tracks.add( + new TrackInfo( + rendererIndex, + groupIndex, + trackIndex, + trackNameProvider.getTrackName(format), + trackIsSelected)); + } + } + } + } + + private void updateTimeline() { + @Nullable Player player = this.player; + if (player == null) { + return; + } + multiWindowTimeBar = + showMultiWindowTimeBar && canShowMultiWindowTimeBar(player.getCurrentTimeline(), window); + currentWindowOffset = 0; + long durationUs = 0; + int adGroupCount = 0; + Timeline timeline = player.getCurrentTimeline(); + if (!timeline.isEmpty()) { + int currentWindowIndex = player.getCurrentWindowIndex(); + int firstWindowIndex = multiWindowTimeBar ? 0 : currentWindowIndex; + int lastWindowIndex = multiWindowTimeBar ? timeline.getWindowCount() - 1 : currentWindowIndex; + for (int i = firstWindowIndex; i <= lastWindowIndex; i++) { + if (i == currentWindowIndex) { + currentWindowOffset = C.usToMs(durationUs); + } + timeline.getWindow(i, window); + if (window.durationUs == C.TIME_UNSET) { + Assertions.checkState(!multiWindowTimeBar); + break; + } + for (int j = window.firstPeriodIndex; j <= window.lastPeriodIndex; j++) { + timeline.getPeriod(j, period); + int periodAdGroupCount = period.getAdGroupCount(); + for (int adGroupIndex = 0; adGroupIndex < periodAdGroupCount; adGroupIndex++) { + long adGroupTimeInPeriodUs = period.getAdGroupTimeUs(adGroupIndex); + if (adGroupTimeInPeriodUs == C.TIME_END_OF_SOURCE) { + if (period.durationUs == C.TIME_UNSET) { + // Don't show ad markers for postrolls in periods with unknown duration. + continue; + } + adGroupTimeInPeriodUs = period.durationUs; + } + long adGroupTimeInWindowUs = adGroupTimeInPeriodUs + period.getPositionInWindowUs(); + if (adGroupTimeInWindowUs >= 0) { + if (adGroupCount == adGroupTimesMs.length) { + int newLength = adGroupTimesMs.length == 0 ? 1 : adGroupTimesMs.length * 2; + adGroupTimesMs = Arrays.copyOf(adGroupTimesMs, newLength); + playedAdGroups = Arrays.copyOf(playedAdGroups, newLength); + } + adGroupTimesMs[adGroupCount] = C.usToMs(durationUs + adGroupTimeInWindowUs); + playedAdGroups[adGroupCount] = period.hasPlayedAdGroup(adGroupIndex); + adGroupCount++; + } + } + } + durationUs += window.durationUs; + } + } + long durationMs = C.usToMs(durationUs); + if (durationView != null) { + durationView.setText(Util.getStringForTime(formatBuilder, formatter, durationMs)); + } + if (timeBar != null) { + timeBar.setDuration(durationMs); + int extraAdGroupCount = extraAdGroupTimesMs.length; + int totalAdGroupCount = adGroupCount + extraAdGroupCount; + if (totalAdGroupCount > adGroupTimesMs.length) { + adGroupTimesMs = Arrays.copyOf(adGroupTimesMs, totalAdGroupCount); + playedAdGroups = Arrays.copyOf(playedAdGroups, totalAdGroupCount); + } + System.arraycopy(extraAdGroupTimesMs, 0, adGroupTimesMs, adGroupCount, extraAdGroupCount); + System.arraycopy(extraPlayedAdGroups, 0, playedAdGroups, adGroupCount, extraAdGroupCount); + timeBar.setAdGroupTimesMs(adGroupTimesMs, playedAdGroups, totalAdGroupCount); + } + updateProgress(); + } + + private void updateProgress() { + if (!isVisible() || !isAttachedToWindow) { + return; + } + @Nullable Player player = this.player; + long position = 0; + long bufferedPosition = 0; + if (player != null) { + position = currentWindowOffset + player.getContentPosition(); + bufferedPosition = currentWindowOffset + player.getContentBufferedPosition(); + } + if (positionView != null && !scrubbing) { + positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); + } + if (timeBar != null) { + timeBar.setPosition(position); + timeBar.setBufferedPosition(bufferedPosition); + } + if (progressUpdateListener != null) { + progressUpdateListener.onProgressUpdate(position, bufferedPosition); + } + + // Cancel any pending updates and schedule a new one if necessary. + removeCallbacks(updateProgressAction); + int playbackState = player == null ? Player.STATE_IDLE : player.getPlaybackState(); + if (player != null && player.isPlaying()) { + long mediaTimeDelayMs = + timeBar != null ? timeBar.getPreferredUpdateDelay() : MAX_UPDATE_INTERVAL_MS; + + // Limit delay to the start of the next full second to ensure position display is smooth. + long mediaTimeUntilNextFullSecondMs = 1000 - position % 1000; + mediaTimeDelayMs = Math.min(mediaTimeDelayMs, mediaTimeUntilNextFullSecondMs); + + // Calculate the delay until the next update in real time, taking playbackSpeed into account. + float playbackSpeed = player.getPlaybackSpeed(); + long delayMs = + playbackSpeed > 0 ? (long) (mediaTimeDelayMs / playbackSpeed) : MAX_UPDATE_INTERVAL_MS; + + // Constrain the delay to avoid too frequent / infrequent updates. + delayMs = Util.constrainValue(delayMs, timeBarMinUpdateIntervalMs, MAX_UPDATE_INTERVAL_MS); + postDelayed(updateProgressAction, delayMs); + } else if (playbackState != Player.STATE_ENDED && playbackState != Player.STATE_IDLE) { + postDelayed(updateProgressAction, MAX_UPDATE_INTERVAL_MS); + } + } + + private void updateSettingsPlaybackSpeedLists() { + if (player == null) { + return; + } + float speed = player.getPlaybackSpeed(); + int currentSpeedMultBy100 = Math.round(speed * 100); + int indexForCurrentSpeed = playbackSpeedMultBy100List.indexOf(currentSpeedMultBy100); + if (indexForCurrentSpeed == UNDEFINED_POSITION) { + if (customPlaybackSpeedIndex != UNDEFINED_POSITION) { + playbackSpeedMultBy100List.remove(customPlaybackSpeedIndex); + playbackSpeedTextList.remove(customPlaybackSpeedIndex); + customPlaybackSpeedIndex = UNDEFINED_POSITION; + } + indexForCurrentSpeed = + -Collections.binarySearch(playbackSpeedMultBy100List, currentSpeedMultBy100) - 1; + String customSpeedText = + resources.getString(R.string.exo_controls_custom_playback_speed, speed); + playbackSpeedMultBy100List.add(indexForCurrentSpeed, currentSpeedMultBy100); + playbackSpeedTextList.add(indexForCurrentSpeed, customSpeedText); + customPlaybackSpeedIndex = indexForCurrentSpeed; + } + + selectedPlaybackSpeedIndex = indexForCurrentSpeed; + settingsAdapter.updateSubTexts( + SETTINGS_PLAYBACK_SPEED_POSITION, playbackSpeedTextList.get(indexForCurrentSpeed)); + } + + private void updateSettingsWindowSize() { + settingsView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + + int maxWidth = getWidth() - settingsWindowMargin * 2; + int itemWidth = settingsView.getMeasuredWidth(); + int width = Math.min(itemWidth, maxWidth); + settingsWindow.setWidth(width); + + int maxHeight = getHeight() - settingsWindowMargin * 2; + int totalHeight = settingsView.getMeasuredHeight(); + int height = Math.min(maxHeight, totalHeight); + settingsWindow.setHeight(height); + } + + private void displaySettingsWindow(RecyclerView.Adapter adapter) { + settingsView.setAdapter(adapter); + + updateSettingsWindowSize(); + + needToHideBars = false; + settingsWindow.dismiss(); + needToHideBars = true; + + int xoff = getWidth() - settingsWindow.getWidth() - settingsWindowMargin; + int yoff = -settingsWindow.getHeight() - settingsWindowMargin; + + settingsWindow.showAsDropDown(this, xoff, yoff); + } + + private void setPlaybackSpeed(float speed) { + if (player == null) { + return; + } + player.setPlaybackSpeed(speed); + } + + /* package */ void requestPlayPauseFocus() { + if (playPauseButton != null) { + playPauseButton.requestFocus(); + } + } + + private void updateButton(boolean visible, boolean enabled, @Nullable View view) { + if (view == null) { + return; + } + view.setEnabled(enabled); + view.setAlpha(enabled ? buttonAlphaEnabled : buttonAlphaDisabled); + view.setVisibility(visible ? VISIBLE : GONE); + } + + private void seekToTimeBarPosition(Player player, long positionMs) { + int windowIndex; + Timeline timeline = player.getCurrentTimeline(); + if (multiWindowTimeBar && !timeline.isEmpty()) { + int windowCount = timeline.getWindowCount(); + windowIndex = 0; + while (true) { + long windowDurationMs = timeline.getWindow(windowIndex, window).getDurationMs(); + if (positionMs < windowDurationMs) { + break; + } else if (windowIndex == windowCount - 1) { + // Seeking past the end of the last window should seek to the end of the timeline. + positionMs = windowDurationMs; + break; + } + positionMs -= windowDurationMs; + windowIndex++; + } + } else { + windowIndex = player.getCurrentWindowIndex(); + } + boolean dispatched = seekTo(player, windowIndex, positionMs); + if (!dispatched) { + // The seek wasn't dispatched then the progress bar scrubber will be in the wrong position. + // Trigger a progress update to snap it back. + updateProgress(); + } + } + + private boolean seekTo(Player player, int windowIndex, long positionMs) { + return controlDispatcher.dispatchSeekTo(player, windowIndex, positionMs); + } + + private final OnClickListener fullScreenModeChangedListener = + new OnClickListener() { + + @Override + public void onClick(View v) { + if (onFullScreenModeChangedListener == null || fullScreenButton == null) { + return; + } + + isFullScreen = !isFullScreen; + if (isFullScreen) { + fullScreenButton.setImageDrawable(fullScreenExitDrawable); + fullScreenButton.setContentDescription(fullScreenExitContentDescription); + } else { + fullScreenButton.setImageDrawable(fullScreenEnterDrawable); + fullScreenButton.setContentDescription(fullScreenEnterContentDescription); + } + + if (onFullScreenModeChangedListener != null) { + onFullScreenModeChangedListener.onFullScreenModeChanged(isFullScreen); + } + } + }; + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + controlViewLayoutManager.onViewAttached(this); + isAttachedToWindow = true; + if (isFullyVisible()) { + controlViewLayoutManager.resetHideCallbacks(); + } + updateAll(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + controlViewLayoutManager.onViewDetached(this); + isAttachedToWindow = false; + removeCallbacks(updateProgressAction); + controlViewLayoutManager.removeHideCallbacks(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + return dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event); + } + + /** + * Called to process media key events. Any {@link KeyEvent} can be passed but only media key + * events will be handled. + * + * @param event A key event. + * @return Whether the key event was handled. + */ + public boolean dispatchMediaKeyEvent(KeyEvent event) { + int keyCode = event.getKeyCode(); + @Nullable Player player = this.player; + if (player == null || !isHandledMediaKey(keyCode)) { + return false; + } + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) { + controlDispatcher.dispatchFastForward(player); + } else if (keyCode == KeyEvent.KEYCODE_MEDIA_REWIND) { + controlDispatcher.dispatchRewind(player); + } else if (event.getRepeatCount() == 0) { + switch (keyCode) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + controlDispatcher.dispatchSetPlayWhenReady(player, !player.getPlayWhenReady()); + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + controlDispatcher.dispatchSetPlayWhenReady(player, true); + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + controlDispatcher.dispatchSetPlayWhenReady(player, false); + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + controlDispatcher.dispatchNext(player); + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + controlDispatcher.dispatchPrevious(player); + break; + default: + break; + } + } + } + return true; + } + + private boolean shouldShowPauseButton() { + return player != null + && player.getPlaybackState() != Player.STATE_ENDED + && player.getPlaybackState() != Player.STATE_IDLE + && player.getPlayWhenReady(); + } + + @SuppressLint("InlinedApi") + private static boolean isHandledMediaKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD + || keyCode == KeyEvent.KEYCODE_MEDIA_REWIND + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY + || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS; + } + + /** + * Returns whether the specified {@code timeline} can be shown on a multi-window time bar. + * + * @param timeline The {@link Timeline} to check. + * @param window A scratch {@link Timeline.Window} instance. + * @return Whether the specified timeline can be shown on a multi-window time bar. + */ + private static boolean canShowMultiWindowTimeBar(Timeline timeline, Timeline.Window window) { + if (timeline.getWindowCount() > MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR) { + return false; + } + int windowCount = timeline.getWindowCount(); + for (int i = 0; i < windowCount; i++) { + if (timeline.getWindow(i, window).durationUs == C.TIME_UNSET) { + return false; + } + } + return true; + } + + private final class ComponentListener + implements Player.EventListener, + TimeBar.OnScrubListener, + OnClickListener, + PopupWindow.OnDismissListener { + + @Override + public void onScrubStart(TimeBar timeBar, long position) { + scrubbing = true; + if (positionView != null) { + positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); + } + controlViewLayoutManager.removeHideCallbacks(); + } + + @Override + public void onScrubMove(TimeBar timeBar, long position) { + if (positionView != null) { + positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); + } + } + + @Override + public void onScrubStop(TimeBar timeBar, long position, boolean canceled) { + scrubbing = false; + if (!canceled && player != null) { + seekToTimeBarPosition(player, position); + } + controlViewLayoutManager.resetHideCallbacks(); + } + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + updatePlayPauseButton(); + updateProgress(); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int state) { + updatePlayPauseButton(); + updateProgress(); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + updateProgress(); + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + updateRepeatModeButton(); + updateNavigation(); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + updateShuffleButton(); + updateNavigation(); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + updateNavigation(); + updateTimeline(); + } + + @Override + public void onPlaybackSpeedChanged(float playbackSpeed) { + updateSettingsPlaybackSpeedLists(); + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + updateTrackLists(); + } + + @Override + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + updateNavigation(); + updateTimeline(); + } + + @Override + public void onDismiss() { + if (needToHideBars) { + controlViewLayoutManager.resetHideCallbacks(); + } + } + + @Override + public void onClick(View view) { + @Nullable Player player = StyledPlayerControlView.this.player; + if (player == null) { + return; + } + controlViewLayoutManager.resetHideCallbacks(); + if (nextButton == view) { + controlDispatcher.dispatchNext(player); + } else if (previousButton == view) { + controlDispatcher.dispatchPrevious(player); + } else if (fastForwardButton == view) { + controlDispatcher.dispatchFastForward(player); + } else if (rewindButton == view) { + controlDispatcher.dispatchRewind(player); + } else if (playPauseButton == view) { + if (player.getPlaybackState() == Player.STATE_IDLE) { + if (playbackPreparer != null) { + playbackPreparer.preparePlayback(); + } + } else if (player.getPlaybackState() == Player.STATE_ENDED) { + seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); + } + controlDispatcher.dispatchSetPlayWhenReady(player, !player.isPlaying()); + } else if (repeatToggleButton == view) { + controlDispatcher.dispatchSetRepeatMode( + player, RepeatModeUtil.getNextRepeatMode(player.getRepeatMode(), repeatToggleModes)); + } else if (shuffleButton == view) { + controlDispatcher.dispatchSetShuffleModeEnabled(player, !player.getShuffleModeEnabled()); + } else if (settingsButton == view) { + controlViewLayoutManager.removeHideCallbacks(); + displaySettingsWindow(settingsAdapter); + } else if (subtitleButton == view) { + controlViewLayoutManager.removeHideCallbacks(); + displaySettingsWindow(textTrackSelectionAdapter); + } + } + } + + private class SettingsAdapter extends RecyclerView.Adapter { + private List mainTexts; + @Nullable private List subTexts; + @Nullable private TypedArray iconIds; + + public SettingsAdapter(List mainTexts, @Nullable TypedArray iconIds) { + this.mainTexts = mainTexts; + this.subTexts = Arrays.asList(new String[mainTexts.size()]); + this.iconIds = iconIds; + } + + @Override + public SettingsViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + View v = + LayoutInflater.from(getContext()).inflate(R.layout.exo_styled_settings_list_item, null); + return new SettingsViewHolder(v); + } + + @Override + public void onBindViewHolder(SettingsViewHolder holder, int position) { + holder.mainTextView.setText(mainTexts.get(position)); + + if (subTexts == null || subTexts.get(position) == null) { + holder.subTextView.setVisibility(GONE); + } else { + holder.subTextView.setText(subTexts.get(position)); + } + + if (iconIds == null || iconIds.getDrawable(position) == null) { + holder.iconView.setVisibility(GONE); + } else { + holder.iconView.setImageDrawable(iconIds.getDrawable(position)); + } + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemCount() { + return mainTexts.size(); + } + + public void updateSubTexts(int position, String subText) { + if (this.subTexts != null) { + this.subTexts.set(position, subText); + } + } + + private class SettingsViewHolder extends RecyclerView.ViewHolder { + TextView mainTextView; + TextView subTextView; + ImageView iconView; + + SettingsViewHolder(View itemView) { + super(itemView); + + mainTextView = itemView.findViewById(R.id.exo_main_text); + subTextView = itemView.findViewById(R.id.exo_sub_text); + iconView = itemView.findViewById(R.id.exo_icon); + + itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + int position = SettingsViewHolder.this.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } + + if (position == SETTINGS_PLAYBACK_SPEED_POSITION) { + subSettingsAdapter.setTexts(playbackSpeedTextList); + subSettingsAdapter.setCheckPosition(selectedPlaybackSpeedIndex); + selectedMainSettingsPosition = SETTINGS_PLAYBACK_SPEED_POSITION; + displaySettingsWindow(subSettingsAdapter); + } else if (position == SETTINGS_AUDIO_TRACK_SELECTION_POSITION) { + selectedMainSettingsPosition = SETTINGS_AUDIO_TRACK_SELECTION_POSITION; + displaySettingsWindow(audioTrackSelectionAdapter); + } else { + settingsWindow.dismiss(); + } + } + }); + } + } + } + + private class SubSettingsAdapter + extends RecyclerView.Adapter { + @Nullable private List texts; + private int checkPosition; + + @Override + public SubSettingsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View v = + LayoutInflater.from(getContext()) + .inflate(R.layout.exo_styled_sub_settings_list_item, null); + return new SubSettingsViewHolder(v); + } + + @Override + public void onBindViewHolder(SubSettingsViewHolder holder, int position) { + if (texts != null) { + holder.textView.setText(texts.get(position)); + } + holder.checkView.setVisibility(position == checkPosition ? VISIBLE : INVISIBLE); + } + + @Override + public int getItemCount() { + return texts != null ? texts.size() : 0; + } + + public void setTexts(@Nullable List texts) { + this.texts = texts; + } + + public void setCheckPosition(int checkPosition) { + this.checkPosition = checkPosition; + } + + private class SubSettingsViewHolder extends RecyclerView.ViewHolder { + TextView textView; + View checkView; + + SubSettingsViewHolder(View itemView) { + super(itemView); + + textView = itemView.findViewById(R.id.exo_text); + checkView = itemView.findViewById(R.id.exo_check); + + itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + int position = SubSettingsViewHolder.this.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } + + if (selectedMainSettingsPosition == SETTINGS_PLAYBACK_SPEED_POSITION) { + if (position != selectedPlaybackSpeedIndex) { + float speed = playbackSpeedMultBy100List.get(position) / 100.0f; + setPlaybackSpeed(speed); + } + } + settingsWindow.dismiss(); + } + }); + } + } + } + + private static final class TrackInfo { + public final int rendererIndex; + public final int groupIndex; + public final int trackIndex; + public final String trackName; + public final boolean selected; + + public TrackInfo( + int rendererIndex, int groupIndex, int trackIndex, String trackName, boolean selected) { + this.rendererIndex = rendererIndex; + this.groupIndex = groupIndex; + this.trackIndex = trackIndex; + this.trackName = trackName; + this.selected = selected; + } + } + + private final class TextTrackSelectionAdapter extends TrackSelectionAdapter { + @Override + public void init( + List rendererIndices, + List trackInfos, + MappedTrackInfo mappedTrackInfo) { + this.rendererIndices = rendererIndices; + this.tracks = trackInfos; + this.mappedTrackInfo = mappedTrackInfo; + } + + @Override + public void onBindViewHolderAtZeroPosition(TrackSelectionViewHolder holder) { + // CC options include "Off" at the first position, which disables text rendering. + holder.textView.setText(R.string.exo_track_selection_none); + boolean isTrackSelectionOff = true; + for (int i = 0; i < tracks.size(); i++) { + if (tracks.get(i).selected) { + isTrackSelectionOff = false; + break; + } + } + holder.checkView.setVisibility(isTrackSelectionOff ? VISIBLE : INVISIBLE); + holder.itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + if (trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + parametersBuilder = + parametersBuilder + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, true); + } + checkNotNull(trackSelector).setParameters(parametersBuilder); + settingsWindow.dismiss(); + } + } + }); + } + + @Override + public void updateSettingsSubtext(String subtext) { + // Do nothing. Text track selection exists outside of Settings menu. + } + } + + private final class AudioTrackSelectionAdapter extends TrackSelectionAdapter { + + @Override + public void onBindViewHolderAtZeroPosition(TrackSelectionViewHolder holder) { + // Audio track selection option includes "Auto" at the top. + holder.textView.setText(R.string.exo_track_selection_auto); + // hasSelectionOverride is true means there is an explicit track selection, not "Auto". + boolean hasSelectionOverride = false; + DefaultTrackSelector.Parameters parameters = checkNotNull(trackSelector).getParameters(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + TrackGroupArray trackGroups = checkNotNull(mappedTrackInfo).getTrackGroups(rendererIndex); + if (parameters.hasSelectionOverride(rendererIndex, trackGroups)) { + hasSelectionOverride = true; + break; + } + } + holder.checkView.setVisibility(hasSelectionOverride ? INVISIBLE : VISIBLE); + holder.itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + if (trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + parametersBuilder = parametersBuilder.clearSelectionOverrides(rendererIndex); + } + checkNotNull(trackSelector).setParameters(parametersBuilder); + } + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_auto)); + settingsWindow.dismiss(); + } + }); + } + + @Override + public void updateSettingsSubtext(String subtext) { + settingsAdapter.updateSubTexts(SETTINGS_AUDIO_TRACK_SELECTION_POSITION, subtext); + } + + @Override + public void init( + List rendererIndices, + List trackInfos, + MappedTrackInfo mappedTrackInfo) { + // Update subtext in settings menu with current audio track selection. + boolean hasSelectionOverride = false; + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + if (trackSelector != null + && trackSelector.getParameters().hasSelectionOverride(rendererIndex, trackGroups)) { + hasSelectionOverride = true; + break; + } + } + if (trackInfos.isEmpty()) { + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_none)); + // TODO(insun) : Make the audio item in main settings (settingsAdapater) + // to be non-clickable. + } else if (!hasSelectionOverride) { + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_auto)); + } else { + for (int i = 0; i < tracks.size(); i++) { + TrackInfo track = tracks.get(i); + if (track.selected) { + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, track.trackName); + break; + } + } + } + this.rendererIndices = rendererIndices; + this.tracks = trackInfos; + this.mappedTrackInfo = mappedTrackInfo; + } + } + + private abstract class TrackSelectionAdapter + extends RecyclerView.Adapter { + protected List rendererIndices; + protected List tracks; + protected @Nullable MappedTrackInfo mappedTrackInfo; + + public TrackSelectionAdapter() { + this.rendererIndices = new ArrayList<>(); + this.tracks = new ArrayList<>(); + this.mappedTrackInfo = null; + } + + public abstract void init( + List rendererIndices, List trackInfos, MappedTrackInfo mappedTrackInfo); + + @Override + public TrackSelectionViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View v = + LayoutInflater.from(getContext()) + .inflate(R.layout.exo_styled_sub_settings_list_item, null); + return new TrackSelectionViewHolder(v); + } + + public abstract void onBindViewHolderAtZeroPosition(TrackSelectionViewHolder holder); + + public abstract void updateSettingsSubtext(String subtext); + + @Override + public void onBindViewHolder(TrackSelectionViewHolder holder, int position) { + if (trackSelector == null || mappedTrackInfo == null) { + return; + } + if (position == 0) { + onBindViewHolderAtZeroPosition(holder); + } else { + TrackInfo track = tracks.get(position - 1); + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(track.rendererIndex); + boolean explicitlySelected = + checkNotNull(trackSelector) + .getParameters() + .hasSelectionOverride(track.rendererIndex, trackGroups) + && track.selected; + holder.textView.setText(track.trackName); + holder.checkView.setVisibility(explicitlySelected ? VISIBLE : INVISIBLE); + holder.itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + if (mappedTrackInfo != null && trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + if (rendererIndex == track.rendererIndex) { + parametersBuilder = + parametersBuilder + .setSelectionOverride( + rendererIndex, + checkNotNull(mappedTrackInfo).getTrackGroups(rendererIndex), + new SelectionOverride(track.groupIndex, track.trackIndex)) + .setRendererDisabled(rendererIndex, false); + } else { + parametersBuilder = + parametersBuilder + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, true); + } + } + checkNotNull(trackSelector).setParameters(parametersBuilder); + updateSettingsSubtext(track.trackName); + settingsWindow.dismiss(); + } + } + }); + } + } + + @Override + public int getItemCount() { + return tracks.isEmpty() ? 0 : tracks.size() + 1; + } + + public void clear() { + tracks = Collections.emptyList(); + mappedTrackInfo = null; + } + } + + private static class TrackSelectionViewHolder extends RecyclerView.ViewHolder { + public final TextView textView; + public final View checkView; + + public TrackSelectionViewHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.exo_text); + checkView = itemView.findViewById(R.id.exo_check); + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java new file mode 100644 index 0000000000..ef89023406 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java @@ -0,0 +1,736 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.animation.LinearInterpolator; +import androidx.annotation.Nullable; +import java.util.ArrayList; + +/* package */ final class StyledPlayerControlViewLayoutManager + implements View.OnLayoutChangeListener { + private static final long ANIMATION_INTERVAL_MS = 2_000; + private static final long DURATION_FOR_HIDING_ANIMATION_MS = 250; + private static final long DURATION_FOR_SHOWING_ANIMATION_MS = 250; + + // Int for defining the UX state where all the views (TitleBar, ProgressBar, BottomBar) are + // all visible. + private static final int UX_STATE_ALL_VISIBLE = 0; + // Int for defining the UX state where only the ProgressBar view is visible. + private static final int UX_STATE_ONLY_PROGRESS_VISIBLE = 1; + // Int for defining the UX state where none of the views are visible. + private static final int UX_STATE_NONE_VISIBLE = 2; + // Int for defining the UX state where the views are being animated to be hidden. + private static final int UX_STATE_ANIMATING_HIDE = 3; + // Int for defining the UX state where the views are being animated to be shown. + private static final int UX_STATE_ANIMATING_SHOW = 4; + + private int uxState = UX_STATE_ALL_VISIBLE; + private boolean isMinimalMode; + private boolean needToShowBars; + private boolean disableAnimation = false; + + @Nullable private StyledPlayerControlView styledPlayerControlView; + + @Nullable private ViewGroup titleBar; + @Nullable private ViewGroup embeddedTransportControls; + @Nullable private ViewGroup bottomBar; + @Nullable private ViewGroup minimalControls; + @Nullable private ViewGroup basicControls; + @Nullable private ViewGroup extraControls; + @Nullable private ViewGroup extraControlsScrollView; + @Nullable private ViewGroup timeView; + @Nullable private View timeBar; + @Nullable private View overflowShowButton; + + @Nullable private AnimatorSet hideMainBarsAnimator; + @Nullable private AnimatorSet hideProgressBarAnimator; + @Nullable private AnimatorSet hideAllBarsAnimator; + @Nullable private AnimatorSet showMainBarsAnimator; + @Nullable private AnimatorSet showAllBarsAnimator; + @Nullable private ValueAnimator overflowShowAnimator; + @Nullable private ValueAnimator overflowHideAnimator; + + void show() { + if (this.styledPlayerControlView == null) { + return; + } + StyledPlayerControlView styledPlayerControlView = this.styledPlayerControlView; + if (!styledPlayerControlView.isVisible()) { + styledPlayerControlView.setVisibility(View.VISIBLE); + styledPlayerControlView.updateAll(); + styledPlayerControlView.requestPlayPauseFocus(); + } + styledPlayerControlView.post(showAllBars); + } + + void hide() { + if (styledPlayerControlView == null + || uxState == UX_STATE_ANIMATING_HIDE + || uxState == UX_STATE_NONE_VISIBLE) { + return; + } + removeHideCallbacks(); + if (isAnimationDisabled()) { + postDelayedRunnable(hideController, 0); + } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { + postDelayedRunnable(hideProgressBar, 0); + } else { + postDelayedRunnable(hideAllBars, 0); + } + } + + void setDisableAnimation(boolean disableAnimation) { + this.disableAnimation = disableAnimation; + } + + void resetHideCallbacks() { + if (uxState == UX_STATE_ANIMATING_HIDE) { + return; + } + removeHideCallbacks(); + int showTimeoutMs = + styledPlayerControlView != null ? styledPlayerControlView.getShowTimeoutMs() : 0; + if (showTimeoutMs > 0) { + if (isAnimationDisabled()) { + postDelayedRunnable(hideController, showTimeoutMs); + } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { + postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); + } else { + postDelayedRunnable(hideMainBars, showTimeoutMs); + } + } + } + + void removeHideCallbacks() { + if (styledPlayerControlView == null) { + return; + } + styledPlayerControlView.removeCallbacks(hideController); + styledPlayerControlView.removeCallbacks(hideAllBars); + styledPlayerControlView.removeCallbacks(hideMainBars); + styledPlayerControlView.removeCallbacks(hideProgressBar); + } + + void onViewAttached(StyledPlayerControlView v) { + styledPlayerControlView = v; + + v.addOnLayoutChangeListener(this); + + // Relating to Title Bar View + ViewGroup titleBar = v.findViewById(R.id.exo_title_bar); + + // Relating to Center View + ViewGroup centerView = v.findViewById(R.id.exo_center_view); + embeddedTransportControls = v.findViewById(R.id.exo_embedded_transport_controls); + + // Relating to Minimal Layout + minimalControls = v.findViewById(R.id.exo_minimal_controls); + + // Relating to Bottom Bar View + ViewGroup bottomBar = v.findViewById(R.id.exo_bottom_bar); + + // Relating to Bottom Bar Left View + timeView = v.findViewById(R.id.exo_time); + View timeBar = v.findViewById(R.id.exo_progress); + + // Relating to Bottom Bar Right View + basicControls = v.findViewById(R.id.exo_basic_controls); + extraControls = v.findViewById(R.id.exo_extra_controls); + extraControlsScrollView = v.findViewById(R.id.exo_extra_controls_scroll_view); + overflowShowButton = v.findViewById(R.id.exo_overflow_show); + View overflowHideButton = v.findViewById(R.id.exo_overflow_hide); + if (overflowShowButton != null && overflowHideButton != null) { + overflowShowButton.setOnClickListener(overflowListener); + overflowHideButton.setOnClickListener(overflowListener); + } + + this.titleBar = titleBar; + this.bottomBar = bottomBar; + this.timeBar = timeBar; + + Resources resources = v.getResources(); + float titleBarHeight = resources.getDimension(R.dimen.exo_title_bar_height); + float progressBarHeight = resources.getDimension(R.dimen.exo_custom_progress_thumb_size); + float bottomBarHeight = resources.getDimension(R.dimen.exo_bottom_bar_height); + + ValueAnimator fadeOutAnimator = ValueAnimator.ofFloat(1.0f, 0.0f); + fadeOutAnimator.setInterpolator(new LinearInterpolator()); + fadeOutAnimator.addUpdateListener( + animation -> { + float animatedValue = (float) animation.getAnimatedValue(); + + if (centerView != null) { + centerView.setAlpha(animatedValue); + } + if (minimalControls != null) { + minimalControls.setAlpha(animatedValue); + } + }); + fadeOutAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (timeBar instanceof DefaultTimeBar && !isMinimalMode) { + ((DefaultTimeBar) timeBar).hideScrubber(DURATION_FOR_HIDING_ANIMATION_MS); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (centerView != null) { + centerView.setVisibility(View.INVISIBLE); + } + if (minimalControls != null) { + minimalControls.setVisibility(View.INVISIBLE); + } + } + }); + + ValueAnimator fadeInAnimator = ValueAnimator.ofFloat(0.0f, 1.0f); + fadeInAnimator.setInterpolator(new LinearInterpolator()); + fadeInAnimator.addUpdateListener( + animation -> { + float animatedValue = (float) animation.getAnimatedValue(); + + if (centerView != null) { + centerView.setAlpha(animatedValue); + } + if (minimalControls != null) { + minimalControls.setAlpha(animatedValue); + } + }); + fadeInAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (centerView != null) { + centerView.setVisibility(View.VISIBLE); + } + if (minimalControls != null) { + minimalControls.setVisibility(isMinimalMode ? View.VISIBLE : View.INVISIBLE); + } + if (timeBar instanceof DefaultTimeBar && !isMinimalMode) { + ((DefaultTimeBar) timeBar).showScrubber(DURATION_FOR_SHOWING_ANIMATION_MS); + } + } + }); + + hideMainBarsAnimator = new AnimatorSet(); + hideMainBarsAnimator.setDuration(DURATION_FOR_HIDING_ANIMATION_MS); + hideMainBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_HIDE); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_ONLY_PROGRESS_VISIBLE); + if (needToShowBars) { + if (styledPlayerControlView != null) { + styledPlayerControlView.post(showAllBars); + } + needToShowBars = false; + } + } + }); + hideMainBarsAnimator + .play(fadeOutAnimator) + .with(ofTranslationY(0, -titleBarHeight, titleBar)) + .with(ofTranslationY(0, bottomBarHeight, timeBar)) + .with(ofTranslationY(0, bottomBarHeight, bottomBar)); + + hideProgressBarAnimator = new AnimatorSet(); + hideProgressBarAnimator.setDuration(DURATION_FOR_HIDING_ANIMATION_MS); + hideProgressBarAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_HIDE); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_NONE_VISIBLE); + if (needToShowBars) { + if (styledPlayerControlView != null) { + styledPlayerControlView.post(showAllBars); + } + needToShowBars = false; + } + } + }); + hideProgressBarAnimator + .play(ofTranslationY(bottomBarHeight, bottomBarHeight + progressBarHeight, timeBar)) + .with(ofTranslationY(bottomBarHeight, bottomBarHeight + progressBarHeight, bottomBar)); + + hideAllBarsAnimator = new AnimatorSet(); + hideAllBarsAnimator.setDuration(DURATION_FOR_HIDING_ANIMATION_MS); + hideAllBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_HIDE); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_NONE_VISIBLE); + if (needToShowBars) { + if (styledPlayerControlView != null) { + styledPlayerControlView.post(showAllBars); + } + needToShowBars = false; + } + } + }); + hideAllBarsAnimator + .play(fadeOutAnimator) + .with(ofTranslationY(0, -titleBarHeight, titleBar)) + .with(ofTranslationY(0, bottomBarHeight + progressBarHeight, timeBar)) + .with(ofTranslationY(0, bottomBarHeight + progressBarHeight, bottomBar)); + + showMainBarsAnimator = new AnimatorSet(); + showMainBarsAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + showMainBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_SHOW); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_ALL_VISIBLE); + } + }); + showMainBarsAnimator + .play(fadeInAnimator) + .with(ofTranslationY(-titleBarHeight, 0, titleBar)) + .with(ofTranslationY(bottomBarHeight, 0, timeBar)) + .with(ofTranslationY(bottomBarHeight, 0, bottomBar)); + + showAllBarsAnimator = new AnimatorSet(); + showAllBarsAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + showAllBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_SHOW); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_ALL_VISIBLE); + } + }); + showAllBarsAnimator + .play(fadeInAnimator) + .with(ofTranslationY(-titleBarHeight, 0, titleBar)) + .with(ofTranslationY(bottomBarHeight + progressBarHeight, 0, timeBar)) + .with(ofTranslationY(bottomBarHeight + progressBarHeight, 0, bottomBar)); + + overflowShowAnimator = ValueAnimator.ofFloat(0.0f, 1.0f); + overflowShowAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + overflowShowAnimator.addUpdateListener( + animation -> animateOverflow((float) animation.getAnimatedValue())); + overflowShowAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (extraControlsScrollView != null) { + extraControlsScrollView.setVisibility(View.VISIBLE); + extraControlsScrollView.setTranslationX(extraControlsScrollView.getWidth()); + extraControlsScrollView.scrollTo(extraControlsScrollView.getWidth(), 0); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (basicControls != null) { + basicControls.setVisibility(View.INVISIBLE); + } + } + }); + + overflowHideAnimator = ValueAnimator.ofFloat(1.0f, 0.0f); + overflowHideAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + overflowHideAnimator.addUpdateListener( + animation -> animateOverflow((float) animation.getAnimatedValue())); + overflowHideAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (basicControls != null) { + basicControls.setVisibility(View.VISIBLE); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (extraControlsScrollView != null) { + extraControlsScrollView.setVisibility(View.INVISIBLE); + } + } + }); + } + + void onViewDetached(StyledPlayerControlView v) { + v.removeOnLayoutChangeListener(this); + } + + boolean isFullyVisible() { + if (styledPlayerControlView == null) { + return false; + } + return uxState == UX_STATE_ALL_VISIBLE; + } + + private void setUxState(int uxState) { + int prevUxState = this.uxState; + this.uxState = uxState; + if (styledPlayerControlView != null) { + StyledPlayerControlView styledPlayerControlView = this.styledPlayerControlView; + if (uxState == UX_STATE_NONE_VISIBLE) { + styledPlayerControlView.setVisibility(View.GONE); + } else if (prevUxState == UX_STATE_NONE_VISIBLE) { + styledPlayerControlView.setVisibility(View.VISIBLE); + } + // TODO(insun): Notify specific uxState. Currently reuses legacy visibility listener for API + // compatibility. + if (prevUxState != uxState) { + styledPlayerControlView.notifyOnVisibilityChange(); + } + } + } + + private boolean isAnimationDisabled() { + return disableAnimation; + } + + private final Runnable showAllBars = + new Runnable() { + @Override + public void run() { + if (isAnimationDisabled()) { + setUxState(UX_STATE_ALL_VISIBLE); + resetHideCallbacks(); + return; + } + + switch (uxState) { + case UX_STATE_NONE_VISIBLE: + if (showAllBarsAnimator != null) { + showAllBarsAnimator.start(); + } + break; + case UX_STATE_ONLY_PROGRESS_VISIBLE: + if (showMainBarsAnimator != null) { + showMainBarsAnimator.start(); + } + break; + case UX_STATE_ANIMATING_HIDE: + needToShowBars = true; + break; + case UX_STATE_ANIMATING_SHOW: + return; + default: + break; + } + resetHideCallbacks(); + } + }; + + private final Runnable hideAllBars = + new Runnable() { + @Override + public void run() { + if (hideAllBarsAnimator == null) { + return; + } + hideAllBarsAnimator.start(); + } + }; + + private final Runnable hideMainBars = + new Runnable() { + @Override + public void run() { + if (hideMainBarsAnimator == null) { + return; + } + hideMainBarsAnimator.start(); + postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); + } + }; + + private final Runnable hideProgressBar = + new Runnable() { + @Override + public void run() { + if (hideProgressBarAnimator == null) { + return; + } + hideProgressBarAnimator.start(); + } + }; + + private final Runnable hideController = + new Runnable() { + @Override + public void run() { + setUxState(UX_STATE_NONE_VISIBLE); + } + }; + + private static ObjectAnimator ofTranslationY(float startValue, float endValue, View target) { + return ObjectAnimator.ofFloat(target, "translationY", startValue, endValue); + } + + private void postDelayedRunnable(Runnable runnable, long interval) { + if (styledPlayerControlView != null && interval >= 0) { + styledPlayerControlView.postDelayed(runnable, interval); + } + } + + private void animateOverflow(float animatedValue) { + if (extraControlsScrollView != null) { + int extraControlTranslationX = + (int) (extraControlsScrollView.getWidth() * (1 - animatedValue)); + extraControlsScrollView.setTranslationX(extraControlTranslationX); + } + + if (timeView != null) { + timeView.setAlpha(1 - animatedValue); + } + if (basicControls != null) { + basicControls.setAlpha(1 - animatedValue); + } + } + + private final OnClickListener overflowListener = + new OnClickListener() { + @Override + public void onClick(View v) { + resetHideCallbacks(); + if (v.getId() == R.id.exo_overflow_show && overflowShowAnimator != null) { + overflowShowAnimator.start(); + } else if (v.getId() == R.id.exo_overflow_hide && overflowHideAnimator != null) { + overflowHideAnimator.start(); + } + } + }; + + @Override + public void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + + boolean shouldBeMinimalMode = shouldBeMinimalMode(); + if (isMinimalMode != shouldBeMinimalMode) { + isMinimalMode = shouldBeMinimalMode; + v.post(() -> updateLayoutForSizeChange()); + } + boolean widthChanged = (right - left) != (oldRight - oldLeft); + if (!isMinimalMode && widthChanged) { + v.post(() -> onLayoutWidthChanged()); + } + } + + private static int getWidth(@Nullable View v) { + return (v != null ? v.getWidth() : 0); + } + + private static int getHeight(@Nullable View v) { + return (v != null ? v.getHeight() : 0); + } + + private boolean shouldBeMinimalMode() { + if (this.styledPlayerControlView == null) { + return isMinimalMode; + } + ViewGroup playerControlView = this.styledPlayerControlView; + + int width = + playerControlView.getWidth() + - playerControlView.getPaddingLeft() + - playerControlView.getPaddingRight(); + int height = + playerControlView.getHeight() + - playerControlView.getPaddingBottom() + - playerControlView.getPaddingTop(); + int defaultModeWidth = + Math.max( + getWidth(embeddedTransportControls), getWidth(timeView) + getWidth(overflowShowButton)); + int defaultModeHeight = + getHeight(embeddedTransportControls) + + getHeight(titleBar) + + getHeight(timeBar) + + getHeight(bottomBar); + + return (width <= defaultModeWidth || height <= defaultModeHeight); + } + + private void updateLayoutForSizeChange() { + if (this.styledPlayerControlView == null) { + return; + } + ViewGroup playerControlView = this.styledPlayerControlView; + + if (minimalControls != null) { + minimalControls.setVisibility(isMinimalMode ? View.VISIBLE : View.INVISIBLE); + } + + View fullScreenButton = playerControlView.findViewById(R.id.exo_fullscreen); + if (fullScreenButton != null) { + ViewGroup parent = (ViewGroup) fullScreenButton.getParent(); + parent.removeView(fullScreenButton); + + if (isMinimalMode && minimalControls != null) { + minimalControls.addView(fullScreenButton); + } else if (!isMinimalMode && basicControls != null) { + int index = Math.max(0, basicControls.getChildCount() - 1); + basicControls.addView(fullScreenButton, index); + } else { + parent.addView(fullScreenButton); + } + } + if (timeBar != null) { + View timeBar = this.timeBar; + MarginLayoutParams timeBarParams = (MarginLayoutParams) timeBar.getLayoutParams(); + int timeBarMarginBottom = + playerControlView + .getResources() + .getDimensionPixelSize(R.dimen.exo_custom_progress_margin_bottom); + timeBarParams.bottomMargin = (isMinimalMode ? 0 : timeBarMarginBottom); + timeBar.setLayoutParams(timeBarParams); + if (timeBar instanceof DefaultTimeBar + && uxState != UX_STATE_ANIMATING_HIDE + && uxState != UX_STATE_ANIMATING_SHOW) { + if (isMinimalMode || uxState != UX_STATE_ALL_VISIBLE) { + ((DefaultTimeBar) timeBar).hideScrubber(); + } else { + ((DefaultTimeBar) timeBar).showScrubber(); + } + } + } + + int[] idsToHideInMinimalMode = { + R.id.exo_title_bar, + R.id.exo_bottom_bar, + R.id.exo_prev, + R.id.exo_next, + R.id.exo_rew, + R.id.exo_rew_with_amount, + R.id.exo_ffwd, + R.id.exo_ffwd_with_amount + }; + for (int id : idsToHideInMinimalMode) { + View v = playerControlView.findViewById(id); + if (v != null) { + v.setVisibility(isMinimalMode ? View.INVISIBLE : View.VISIBLE); + } + } + } + + private void onLayoutWidthChanged() { + if (basicControls == null || extraControls == null) { + return; + } + ViewGroup basicControls = this.basicControls; + ViewGroup extraControls = this.extraControls; + + int width = + (styledPlayerControlView != null + ? styledPlayerControlView.getWidth() + - styledPlayerControlView.getPaddingLeft() + - styledPlayerControlView.getPaddingRight() + : 0); + int basicBottomBarWidth = getWidth(timeView); + for (int i = 0; i < basicControls.getChildCount(); ++i) { + basicBottomBarWidth += basicControls.getChildAt(i).getWidth(); + } + + // BasicControls keeps overflow button at least. + int minBasicControlsChildCount = 1; + // ExtraControls keeps overflow button and settings button at least. + int minExtraControlsChildCount = 2; + + if (basicBottomBarWidth > width) { + // move control views from basicControls to extraControls + ArrayList movingChildren = new ArrayList<>(); + int movingWidth = 0; + int endIndex = basicControls.getChildCount() - minBasicControlsChildCount; + for (int index = 0; index < endIndex; index++) { + View child = basicControls.getChildAt(index); + movingWidth += child.getWidth(); + movingChildren.add(child); + if (basicBottomBarWidth - movingWidth <= width) { + break; + } + } + + if (!movingChildren.isEmpty()) { + basicControls.removeViews(0, movingChildren.size()); + + for (View child : movingChildren) { + int index = extraControls.getChildCount() - minExtraControlsChildCount; + extraControls.addView(child, index); + } + } + + } else { + // move controls from extraControls to basicControls if possible, else do nothing + ArrayList movingChildren = new ArrayList<>(); + int movingWidth = 0; + int startIndex = extraControls.getChildCount() - minExtraControlsChildCount - 1; + for (int index = startIndex; index >= 0; index--) { + View child = extraControls.getChildAt(index); + movingWidth += child.getWidth(); + if (basicBottomBarWidth + movingWidth > width) { + break; + } + movingChildren.add(child); + } + + if (!movingChildren.isEmpty()) { + extraControls.removeViews(startIndex - movingChildren.size() + 1, movingChildren.size()); + + for (View child : movingChildren) { + basicControls.addView(child, 0); + } + } + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java new file mode 100644 index 0000000000..46849979c8 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java @@ -0,0 +1,1709 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ui; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackPreparer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.flac.PictureFrame; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.TextOutput; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; +import com.google.android.exoplayer2.ui.spherical.SingleTapListener; +import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.ErrorMessageProvider; +import com.google.android.exoplayer2.util.RepeatModeUtil; +import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.video.VideoDecoderGLSurfaceView; +import com.google.android.exoplayer2.video.VideoListener; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * A high level view for {@link Player} media playbacks. It displays video, subtitles and album art + * during playback, and displays playback controls using a {@link StyledPlayerControlView}. + * + *

          A StyledPlayerView can be customized by setting attributes (or calling corresponding methods), + * overriding drawables, overriding the view's layout file, or by specifying a custom view layout + * file. + * + *

          Attributes

          + * + * The following attributes can be set on a StyledPlayerView when used in a layout XML file: + * + *
            + *
          • {@code use_artwork} - Whether artwork is used if available in audio streams. + *
              + *
            • Corresponding method: {@link #setUseArtwork(boolean)} + *
            • Default: {@code true} + *
            + *
          • {@code default_artwork} - Default artwork to use if no artwork available in audio + * streams. + *
              + *
            • Corresponding method: {@link #setDefaultArtwork(Drawable)} + *
            • Default: {@code null} + *
            + *
          • {@code use_controller} - Whether the playback controls can be shown. + *
              + *
            • Corresponding method: {@link #setUseController(boolean)} + *
            • Default: {@code true} + *
            + *
          • {@code hide_on_touch} - Whether the playback controls are hidden by touch events. + *
              + *
            • Corresponding method: {@link #setControllerHideOnTouch(boolean)} + *
            • Default: {@code true} + *
            + *
          • {@code auto_show} - Whether the playback controls are automatically shown when + * playback starts, pauses, ends, or fails. If set to false, the playback controls can be + * manually operated with {@link #showController()} and {@link #hideController()}. + *
              + *
            • Corresponding method: {@link #setControllerAutoShow(boolean)} + *
            • Default: {@code true} + *
            + *
          • {@code hide_during_ads} - Whether the playback controls are hidden during ads. + * Controls are always shown during ads if they are enabled and the player is paused. + *
              + *
            • Corresponding method: {@link #setControllerHideDuringAds(boolean)} + *
            • Default: {@code true} + *
            + *
          • {@code show_buffering} - Whether the buffering spinner is displayed when the player + * is buffering. Valid values are {@code never}, {@code when_playing} and {@code always}. + *
              + *
            • Corresponding method: {@link #setShowBuffering(int)} + *
            • Default: {@code never} + *
            + *
          • {@code resize_mode} - Controls how video and album art is resized within the view. + * Valid values are {@code fit}, {@code fixed_width}, {@code fixed_height} and {@code fill}. + *
              + *
            • Corresponding method: {@link #setResizeMode(int)} + *
            • Default: {@code fit} + *
            + *
          • {@code surface_type} - The type of surface view used for video playbacks. Valid + * values are {@code surface_view}, {@code texture_view}, {@code spherical_gl_surface_view}, + * {@code video_decoder_gl_surface_view} and {@code none}. Using {@code none} is recommended + * for audio only applications, since creating the surface can be expensive. Using {@code + * surface_view} is recommended for video applications. Note, TextureView can only be used in + * a hardware accelerated window. When rendered in software, TextureView will draw nothing. + *
              + *
            • Corresponding method: None + *
            • Default: {@code surface_view} + *
            + *
          • {@code use_sensor_rotation} - Whether to use the orientation sensor for rotation + * during spherical playbacks (if available). + *
              + *
            • Corresponding method: {@link #setUseSensorRotation(boolean)} + *
            • Default: {@code true} + *
            + *
          • {@code shutter_background_color} - The background color of the {@code exo_shutter} + * view. + *
              + *
            • Corresponding method: {@link #setShutterBackgroundColor(int)} + *
            • Default: {@code unset} + *
            + *
          • {@code keep_content_on_player_reset} - Whether the currently displayed video frame + * or media artwork is kept visible when the player is reset. + *
              + *
            • Corresponding method: {@link #setKeepContentOnPlayerReset(boolean)} + *
            • Default: {@code false} + *
            + *
          • {@code player_layout_id} - Specifies the id of the layout to be inflated. See below + * for more details. + *
              + *
            • Corresponding method: None + *
            • Default: {@code R.layout.exo_styled_player_view} + *
            + *
          • {@code controller_layout_id} - Specifies the id of the layout resource to be + * inflated by the child {@link StyledPlayerControlView}. See below for more details. + *
              + *
            • Corresponding method: None + *
            • Default: {@code R.layout.exo_styled_player_control_view} + *
            + *
          • All attributes that can be set on {@link StyledPlayerControlView} and {@link + * DefaultTimeBar} can also be set on a StyledPlayerView, and will be propagated to the + * inflated {@link StyledPlayerControlView} unless the layout is overridden to specify a + * custom {@code exo_controller} (see below). + *
          + * + *

          Overriding drawables

          + * + * The drawables used by {@link StyledPlayerControlView} (with its default layout file) can be + * overridden by drawables with the same names defined in your application. See the {@link + * StyledPlayerControlView} documentation for a list of drawables that can be overridden. + * + *

          Overriding the layout file

          + * + * To customize the layout of StyledPlayerView throughout your app, or just for certain + * configurations, you can define {@code exo_player_view.xml} layout files in your application + * {@code res/layout*} directories. These layouts will override the one provided by the ExoPlayer + * library, and will be inflated for use by StyledPlayerView. The view identifies and binds its + * children by looking for the following ids: + * + *
            + *
          • {@code exo_content_frame} - A frame whose aspect ratio is resized based on the video + * or album art of the media being played, and the configured {@code resize_mode}. The video + * surface view is inflated into this frame as its first child. + *
              + *
            • Type: {@link AspectRatioFrameLayout} + *
            + *
          • {@code exo_shutter} - A view that's made visible when video should be hidden. This + * view is typically an opaque view that covers the video surface, thereby obscuring it when + * visible. Obscuring the surface in this way also helps to prevent flicker at the start of + * playback when {@code surface_type="surface_view"}. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_buffering} - A view that's made visible when the player is buffering. + * This view typically displays a buffering spinner or animation. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_subtitles} - Displays subtitles. + *
              + *
            • Type: {@link SubtitleView} + *
            + *
          • {@code exo_artwork} - Displays album art. + *
              + *
            • Type: {@link ImageView} + *
            + *
          • {@code exo_error_message} - Displays an error message to the user if playback fails. + *
              + *
            • Type: {@link TextView} + *
            + *
          • {@code exo_controller_placeholder} - A placeholder that's replaced with the inflated + * {@link StyledPlayerControlView}. Ignored if an {@code exo_controller} view exists. + *
              + *
            • Type: {@link View} + *
            + *
          • {@code exo_controller} - An already inflated {@link StyledPlayerControlView}. Allows + * use of a custom extension of {@link StyledPlayerControlView}. {@link + * StyledPlayerControlView} and {@link DefaultTimeBar} attributes set on the StyledPlayerView + * will not be automatically propagated through to this instance. If a view exists with this + * id, any {@code exo_controller_placeholder} view will be ignored. + *
              + *
            • Type: {@link StyledPlayerControlView} + *
            + *
          • {@code exo_ad_overlay} - A {@link FrameLayout} positioned on top of the player which + * is used to show ad UI (if applicable). + *
              + *
            • Type: {@link FrameLayout} + *
            + *
          • {@code exo_overlay} - A {@link FrameLayout} positioned on top of the player which + * the app can access via {@link #getOverlayFrameLayout()}, provided for convenience. + *
              + *
            • Type: {@link FrameLayout} + *
            + *
          + * + *

          All child views are optional and so can be omitted if not required, however where defined they + * must be of the expected type. + * + *

          Specifying a custom layout file

          + * + * Defining your own {@code exo_styled_player_view.xml} is useful to customize the layout of + * StyledPlayerView throughout your application. It's also possible to customize the layout for a + * single instance in a layout file. This is achieved by setting the {@code player_layout_id} + * attribute on a StyledPlayerView. This will cause the specified layout to be inflated instead of + * {@code exo_styled_player_view.xml} for only the instance on which the attribute is set. + */ +public class StyledPlayerView extends FrameLayout implements AdsLoader.AdViewProvider { + + // LINT.IfChange + /** + * Determines when the buffering view is shown. One of {@link #SHOW_BUFFERING_NEVER}, {@link + * #SHOW_BUFFERING_WHEN_PLAYING} or {@link #SHOW_BUFFERING_ALWAYS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({SHOW_BUFFERING_NEVER, SHOW_BUFFERING_WHEN_PLAYING, SHOW_BUFFERING_ALWAYS}) + public @interface ShowBuffering {} + /** The buffering view is never shown. */ + public static final int SHOW_BUFFERING_NEVER = 0; + /** + * The buffering view is shown when the player is in the {@link Player#STATE_BUFFERING buffering} + * state and {@link Player#getPlayWhenReady() playWhenReady} is {@code true}. + */ + public static final int SHOW_BUFFERING_WHEN_PLAYING = 1; + /** + * The buffering view is always shown when the player is in the {@link Player#STATE_BUFFERING + * buffering} state. + */ + public static final int SHOW_BUFFERING_ALWAYS = 2; + // LINT.ThenChange(../../../../../../res/values/attrs.xml) + + // LINT.IfChange + private static final int SURFACE_TYPE_NONE = 0; + private static final int SURFACE_TYPE_SURFACE_VIEW = 1; + private static final int SURFACE_TYPE_TEXTURE_VIEW = 2; + private static final int SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW = 3; + private static final int SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW = 4; + // LINT.ThenChange(../../../../../../res/values/attrs.xml) + + private final ComponentListener componentListener; + @Nullable private final AspectRatioFrameLayout contentFrame; + @Nullable private final View shutterView; + @Nullable private final View surfaceView; + @Nullable private final ImageView artworkView; + @Nullable private final SubtitleView subtitleView; + @Nullable private final View bufferingView; + @Nullable private final TextView errorMessageView; + @Nullable private final StyledPlayerControlView controller; + @Nullable private final FrameLayout adOverlayFrameLayout; + @Nullable private final FrameLayout overlayFrameLayout; + + @Nullable private Player player; + private boolean useController; + @Nullable private StyledPlayerControlView.VisibilityListener controllerVisibilityListener; + private boolean useArtwork; + @Nullable private Drawable defaultArtwork; + private @ShowBuffering int showBuffering; + private boolean keepContentOnPlayerReset; + private boolean useSensorRotation; + @Nullable private ErrorMessageProvider errorMessageProvider; + @Nullable private CharSequence customErrorMessage; + private int controllerShowTimeoutMs; + private boolean controllerAutoShow; + private boolean controllerHideDuringAds; + private boolean controllerHideOnTouch; + private int textureViewRotation; + private boolean isTouching; + private static final int PICTURE_TYPE_FRONT_COVER = 3; + private static final int PICTURE_TYPE_NOT_SET = -1; + + public StyledPlayerView(Context context) { + this(context, /* attrs= */ null); + } + + public StyledPlayerView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, /* defStyleAttr= */ 0); + } + + @SuppressWarnings({"nullness:argument.type.incompatible", "nullness:method.invocation.invalid"}) + public StyledPlayerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + componentListener = new ComponentListener(); + + if (isInEditMode()) { + contentFrame = null; + shutterView = null; + surfaceView = null; + artworkView = null; + subtitleView = null; + bufferingView = null; + errorMessageView = null; + controller = null; + adOverlayFrameLayout = null; + overlayFrameLayout = null; + ImageView logo = new ImageView(context); + if (Util.SDK_INT >= 23) { + configureEditModeLogoV23(getResources(), logo); + } else { + configureEditModeLogo(getResources(), logo); + } + addView(logo); + return; + } + + boolean shutterColorSet = false; + int shutterColor = 0; + int playerLayoutId = R.layout.exo_player_view; + boolean useArtwork = true; + int defaultArtworkId = 0; + boolean useController = true; + int surfaceType = SURFACE_TYPE_SURFACE_VIEW; + int resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + int controllerShowTimeoutMs = StyledPlayerControlView.DEFAULT_SHOW_TIMEOUT_MS; + boolean controllerHideOnTouch = true; + boolean controllerAutoShow = true; + boolean controllerHideDuringAds = true; + int showBuffering = SHOW_BUFFERING_NEVER; + useSensorRotation = true; + if (attrs != null) { + TypedArray a = + context.getTheme().obtainStyledAttributes(attrs, R.styleable.StyledPlayerView, 0, 0); + try { + shutterColorSet = a.hasValue(R.styleable.StyledPlayerView_shutter_background_color); + shutterColor = + a.getColor(R.styleable.StyledPlayerView_shutter_background_color, shutterColor); + playerLayoutId = + a.getResourceId(R.styleable.StyledPlayerView_player_layout_id, playerLayoutId); + useArtwork = a.getBoolean(R.styleable.StyledPlayerView_use_artwork, useArtwork); + defaultArtworkId = + a.getResourceId(R.styleable.StyledPlayerView_default_artwork, defaultArtworkId); + useController = a.getBoolean(R.styleable.StyledPlayerView_use_controller, useController); + surfaceType = a.getInt(R.styleable.StyledPlayerView_surface_type, surfaceType); + resizeMode = a.getInt(R.styleable.StyledPlayerView_resize_mode, resizeMode); + controllerShowTimeoutMs = + a.getInt(R.styleable.StyledPlayerView_show_timeout, controllerShowTimeoutMs); + controllerHideOnTouch = + a.getBoolean(R.styleable.StyledPlayerView_hide_on_touch, controllerHideOnTouch); + controllerAutoShow = + a.getBoolean(R.styleable.StyledPlayerView_auto_show, controllerAutoShow); + showBuffering = a.getInteger(R.styleable.StyledPlayerView_show_buffering, showBuffering); + keepContentOnPlayerReset = + a.getBoolean( + R.styleable.StyledPlayerView_keep_content_on_player_reset, + keepContentOnPlayerReset); + controllerHideDuringAds = + a.getBoolean(R.styleable.StyledPlayerView_hide_during_ads, controllerHideDuringAds); + useSensorRotation = + a.getBoolean(R.styleable.StyledPlayerView_use_sensor_rotation, useSensorRotation); + } finally { + a.recycle(); + } + } + + LayoutInflater.from(context).inflate(playerLayoutId, this); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + + // Content frame. + contentFrame = findViewById(R.id.exo_content_frame); + if (contentFrame != null) { + setResizeModeRaw(contentFrame, resizeMode); + } + + // Shutter view. + shutterView = findViewById(R.id.exo_shutter); + if (shutterView != null && shutterColorSet) { + shutterView.setBackgroundColor(shutterColor); + } + + // Create a surface view and insert it into the content frame, if there is one. + if (contentFrame != null && surfaceType != SURFACE_TYPE_NONE) { + ViewGroup.LayoutParams params = + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + switch (surfaceType) { + case SURFACE_TYPE_TEXTURE_VIEW: + surfaceView = new TextureView(context); + break; + case SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW: + SphericalGLSurfaceView sphericalGLSurfaceView = new SphericalGLSurfaceView(context); + sphericalGLSurfaceView.setSingleTapListener(componentListener); + sphericalGLSurfaceView.setUseSensorRotation(useSensorRotation); + surfaceView = sphericalGLSurfaceView; + break; + case SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW: + surfaceView = new VideoDecoderGLSurfaceView(context); + break; + default: + surfaceView = new SurfaceView(context); + break; + } + surfaceView.setLayoutParams(params); + contentFrame.addView(surfaceView, 0); + } else { + surfaceView = null; + } + + // Ad overlay frame layout. + adOverlayFrameLayout = findViewById(R.id.exo_ad_overlay); + + // Overlay frame layout. + overlayFrameLayout = findViewById(R.id.exo_overlay); + + // Artwork view. + artworkView = findViewById(R.id.exo_artwork); + this.useArtwork = useArtwork && artworkView != null; + if (defaultArtworkId != 0) { + defaultArtwork = ContextCompat.getDrawable(getContext(), defaultArtworkId); + } + + // Subtitle view. + subtitleView = findViewById(R.id.exo_subtitles); + if (subtitleView != null) { + subtitleView.setUserDefaultStyle(); + subtitleView.setUserDefaultTextSize(); + } + + // Buffering view. + bufferingView = findViewById(R.id.exo_buffering); + if (bufferingView != null) { + bufferingView.setVisibility(View.GONE); + } + this.showBuffering = showBuffering; + + // Error message view. + errorMessageView = findViewById(R.id.exo_error_message); + if (errorMessageView != null) { + errorMessageView.setVisibility(View.GONE); + } + + // Playback control view. + StyledPlayerControlView customController = findViewById(R.id.exo_controller); + View controllerPlaceholder = findViewById(R.id.exo_controller_placeholder); + if (customController != null) { + this.controller = customController; + } else if (controllerPlaceholder != null) { + // Propagate attrs as playbackAttrs so that PlayerControlView's custom attributes are + // transferred, but standard attributes (e.g. background) are not. + this.controller = new StyledPlayerControlView(context, null, 0, attrs); + controller.setId(R.id.exo_controller); + controller.setLayoutParams(controllerPlaceholder.getLayoutParams()); + ViewGroup parent = ((ViewGroup) controllerPlaceholder.getParent()); + int controllerIndex = parent.indexOfChild(controllerPlaceholder); + parent.removeView(controllerPlaceholder); + parent.addView(controller, controllerIndex); + } else { + this.controller = null; + } + this.controllerShowTimeoutMs = controller != null ? controllerShowTimeoutMs : 0; + this.controllerHideOnTouch = controllerHideOnTouch; + this.controllerAutoShow = controllerAutoShow; + this.controllerHideDuringAds = controllerHideDuringAds; + this.useController = useController && controller != null; + hideController(); + updateContentDescription(); + if (controller != null) { + controller.addVisibilityListener(/* listener= */ componentListener); + } + } + + /** + * Switches the view targeted by a given {@link Player}. + * + * @param player The player whose target view is being switched. + * @param oldPlayerView The old view to detach from the player. + * @param newPlayerView The new view to attach to the player. + */ + public static void switchTargetView( + Player player, + @Nullable StyledPlayerView oldPlayerView, + @Nullable StyledPlayerView newPlayerView) { + if (oldPlayerView == newPlayerView) { + return; + } + // We attach the new view before detaching the old one because this ordering allows the player + // to swap directly from one surface to another, without transitioning through a state where no + // surface is attached. This is significantly more efficient and achieves a more seamless + // transition when using platform provided video decoders. + if (newPlayerView != null) { + newPlayerView.setPlayer(player); + } + if (oldPlayerView != null) { + oldPlayerView.setPlayer(null); + } + } + + /** Returns the player currently set on this view, or null if no player is set. */ + @Nullable + public Player getPlayer() { + return player; + } + + /** + * Set the {@link Player} to use. + * + *

          To transition a {@link Player} from targeting one view to another, it's recommended to use + * {@link #switchTargetView(Player, StyledPlayerView, StyledPlayerView)} rather than this method. + * If you do wish to use this method directly, be sure to attach the player to the new view + * before calling {@code setPlayer(null)} to detach it from the old one. This ordering is + * significantly more efficient and may allow for more seamless transitions. + * + * @param player The {@link Player} to use, or {@code null} to detach the current player. Only + * players which are accessed on the main thread are supported ({@code + * player.getApplicationLooper() == Looper.getMainLooper()}). + */ + public void setPlayer(@Nullable Player player) { + Assertions.checkState(Looper.myLooper() == Looper.getMainLooper()); + Assertions.checkArgument( + player == null || player.getApplicationLooper() == Looper.getMainLooper()); + if (this.player == player) { + return; + } + @Nullable Player oldPlayer = this.player; + if (oldPlayer != null) { + oldPlayer.removeListener(componentListener); + @Nullable Player.VideoComponent oldVideoComponent = oldPlayer.getVideoComponent(); + if (oldVideoComponent != null) { + oldVideoComponent.removeVideoListener(componentListener); + if (surfaceView instanceof TextureView) { + oldVideoComponent.clearVideoTextureView((TextureView) surfaceView); + } else if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).setVideoComponent(null); + } else if (surfaceView instanceof VideoDecoderGLSurfaceView) { + oldVideoComponent.setVideoDecoderOutputBufferRenderer(null); + } else if (surfaceView instanceof SurfaceView) { + oldVideoComponent.clearVideoSurfaceView((SurfaceView) surfaceView); + } + } + @Nullable Player.TextComponent oldTextComponent = oldPlayer.getTextComponent(); + if (oldTextComponent != null) { + oldTextComponent.removeTextOutput(componentListener); + } + } + if (subtitleView != null) { + subtitleView.setCues(null); + } + this.player = player; + if (useController()) { + controller.setPlayer(player); + } + updateBuffering(); + updateErrorMessage(); + updateForCurrentTrackSelections(/* isNewPlayer= */ true); + if (player != null) { + @Nullable Player.VideoComponent newVideoComponent = player.getVideoComponent(); + if (newVideoComponent != null) { + if (surfaceView instanceof TextureView) { + newVideoComponent.setVideoTextureView((TextureView) surfaceView); + } else if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).setVideoComponent(newVideoComponent); + } else if (surfaceView instanceof VideoDecoderGLSurfaceView) { + newVideoComponent.setVideoDecoderOutputBufferRenderer( + ((VideoDecoderGLSurfaceView) surfaceView).getVideoDecoderOutputBufferRenderer()); + } else if (surfaceView instanceof SurfaceView) { + newVideoComponent.setVideoSurfaceView((SurfaceView) surfaceView); + } + newVideoComponent.addVideoListener(componentListener); + } + @Nullable Player.TextComponent newTextComponent = player.getTextComponent(); + if (newTextComponent != null) { + newTextComponent.addTextOutput(componentListener); + if (subtitleView != null) { + subtitleView.setCues(newTextComponent.getCurrentCues()); + } + } + player.addListener(componentListener); + maybeShowController(false); + } else { + hideController(); + } + } + + @Override + public void setVisibility(int visibility) { + super.setVisibility(visibility); + if (surfaceView instanceof SurfaceView) { + // Work around https://github.com/google/ExoPlayer/issues/3160. + surfaceView.setVisibility(visibility); + } + } + + /** + * Sets the {@link ResizeMode}. + * + * @param resizeMode The {@link ResizeMode}. + */ + public void setResizeMode(@ResizeMode int resizeMode) { + Assertions.checkStateNotNull(contentFrame); + contentFrame.setResizeMode(resizeMode); + } + + /** Returns the {@link ResizeMode}. */ + public @ResizeMode int getResizeMode() { + Assertions.checkStateNotNull(contentFrame); + return contentFrame.getResizeMode(); + } + + /** Returns whether artwork is displayed if present in the media. */ + public boolean getUseArtwork() { + return useArtwork; + } + + /** + * Sets whether artwork is displayed if present in the media. + * + * @param useArtwork Whether artwork is displayed. + */ + public void setUseArtwork(boolean useArtwork) { + Assertions.checkState(!useArtwork || artworkView != null); + if (this.useArtwork != useArtwork) { + this.useArtwork = useArtwork; + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + } + + /** Returns the default artwork to display. */ + @Nullable + public Drawable getDefaultArtwork() { + return defaultArtwork; + } + + /** + * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is + * present in the media. + * + * @param defaultArtwork the default artwork to display + */ + public void setDefaultArtwork(@Nullable Drawable defaultArtwork) { + if (this.defaultArtwork != defaultArtwork) { + this.defaultArtwork = defaultArtwork; + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + } + + /** Returns whether the playback controls can be shown. */ + public boolean getUseController() { + return useController; + } + + /** + * Sets whether the playback controls can be shown. If set to {@code false} the playback controls + * are never visible and are disconnected from the player. + * + * @param useController Whether the playback controls can be shown. + */ + public void setUseController(boolean useController) { + Assertions.checkState(!useController || controller != null); + if (this.useController == useController) { + return; + } + this.useController = useController; + if (useController()) { + controller.setPlayer(player); + } else if (controller != null) { + controller.hide(); + controller.setPlayer(/* player= */ null); + } + updateContentDescription(); + } + + /** + * Sets the background color of the {@code exo_shutter} view. + * + * @param color The background color. + */ + public void setShutterBackgroundColor(int color) { + if (shutterView != null) { + shutterView.setBackgroundColor(color); + } + } + + /** + * Sets whether the currently displayed video frame or media artwork is kept visible when the + * player is reset. A player reset is defined to mean the player being re-prepared with different + * media, the player transitioning to unprepared media, {@link Player#stop(boolean)} being called + * with {@code reset=true}, or the player being replaced or cleared by calling {@link + * #setPlayer(Player)}. + * + *

          If enabled, the currently displayed video frame or media artwork will be kept visible until + * the player set on the view has been successfully prepared with new media and loaded enough of + * it to have determined the available tracks. Hence enabling this option allows transitioning + * from playing one piece of media to another, or from using one player instance to another, + * without clearing the view's content. + * + *

          If disabled, the currently displayed video frame or media artwork will be hidden as soon as + * the player is reset. Note that the video frame is hidden by making {@code exo_shutter} visible. + * Hence the video frame will not be hidden if using a custom layout that omits this view. + * + * @param keepContentOnPlayerReset Whether the currently displayed video frame or media artwork is + * kept visible when the player is reset. + */ + public void setKeepContentOnPlayerReset(boolean keepContentOnPlayerReset) { + if (this.keepContentOnPlayerReset != keepContentOnPlayerReset) { + this.keepContentOnPlayerReset = keepContentOnPlayerReset; + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + } + + /** + * Sets whether to use the orientation sensor for rotation during spherical playbacks (if + * available) + * + * @param useSensorRotation Whether to use the orientation sensor for rotation during spherical + * playbacks. + */ + public void setUseSensorRotation(boolean useSensorRotation) { + if (this.useSensorRotation != useSensorRotation) { + this.useSensorRotation = useSensorRotation; + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).setUseSensorRotation(useSensorRotation); + } + } + } + + /** + * Sets whether a buffering spinner is displayed when the player is in the buffering state. The + * buffering spinner is not displayed by default. + * + * @param showBuffering The mode that defines when the buffering spinner is displayed. One of + * {@link #SHOW_BUFFERING_NEVER}, {@link #SHOW_BUFFERING_WHEN_PLAYING} and {@link + * #SHOW_BUFFERING_ALWAYS}. + */ + public void setShowBuffering(@ShowBuffering int showBuffering) { + if (this.showBuffering != showBuffering) { + this.showBuffering = showBuffering; + updateBuffering(); + } + } + + /** + * Sets the optional {@link ErrorMessageProvider}. + * + * @param errorMessageProvider The error message provider. + */ + public void setErrorMessageProvider( + @Nullable ErrorMessageProvider errorMessageProvider) { + if (this.errorMessageProvider != errorMessageProvider) { + this.errorMessageProvider = errorMessageProvider; + updateErrorMessage(); + } + } + + /** + * Sets a custom error message to be displayed by the view. The error message will be displayed + * permanently, unless it is cleared by passing {@code null} to this method. + * + * @param message The message to display, or {@code null} to clear a previously set message. + */ + public void setCustomErrorMessage(@Nullable CharSequence message) { + Assertions.checkState(errorMessageView != null); + customErrorMessage = message; + updateErrorMessage(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (player != null && player.isPlayingAd()) { + return super.dispatchKeyEvent(event); + } + + boolean isDpadKey = isDpadKey(event.getKeyCode()); + boolean handled = false; + if (isDpadKey && useController() && !controller.isFullyVisible()) { + // Handle the key event by showing the controller. + maybeShowController(true); + handled = true; + } else if (dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event)) { + // The key event was handled as a media key or by the super class. We should also show the + // controller, or extend its show timeout if already visible. + maybeShowController(true); + handled = true; + } else if (isDpadKey && useController()) { + // The key event wasn't handled, but we should extend the controller's show timeout. + maybeShowController(true); + } + return handled; + } + + /** + * Called to process media key events. Any {@link KeyEvent} can be passed but only media key + * events will be handled. Does nothing if playback controls are disabled. + * + * @param event A key event. + * @return Whether the key event was handled. + */ + public boolean dispatchMediaKeyEvent(KeyEvent event) { + return useController() && controller.dispatchMediaKeyEvent(event); + } + + /** Returns whether the controller is currently fully visible. */ + public boolean isControllerFullyVisible() { + return controller != null && controller.isFullyVisible(); + } + + /** + * Shows the playback controls. Does nothing if playback controls are disabled. + * + *

          The playback controls are automatically hidden during playback after {{@link + * #getControllerShowTimeoutMs()}}. They are shown indefinitely when playback has not started yet, + * is paused, has ended or failed. + */ + public void showController() { + showController(shouldShowControllerIndefinitely()); + } + + /** Hides the playback controls. Does nothing if playback controls are disabled. */ + public void hideController() { + if (controller != null) { + controller.hide(); + } + } + + /** + * Returns the playback controls timeout. The playback controls are automatically hidden after + * this duration of time has elapsed without user input and with playback or buffering in + * progress. + * + * @return The timeout in milliseconds. A non-positive value will cause the controller to remain + * visible indefinitely. + */ + public int getControllerShowTimeoutMs() { + return controllerShowTimeoutMs; + } + + /** + * Sets the playback controls timeout. The playback controls are automatically hidden after this + * duration of time has elapsed without user input and with playback or buffering in progress. + * + * @param controllerShowTimeoutMs The timeout in milliseconds. A non-positive value will cause the + * controller to remain visible indefinitely. + */ + public void setControllerShowTimeoutMs(int controllerShowTimeoutMs) { + Assertions.checkStateNotNull(controller); + this.controllerShowTimeoutMs = controllerShowTimeoutMs; + if (controller.isFullyVisible()) { + // Update the controller's timeout if necessary. + showController(); + } + } + + /** Returns whether the playback controls are hidden by touch events. */ + public boolean getControllerHideOnTouch() { + return controllerHideOnTouch; + } + + /** + * Sets whether the playback controls are hidden by touch events. + * + * @param controllerHideOnTouch Whether the playback controls are hidden by touch events. + */ + public void setControllerHideOnTouch(boolean controllerHideOnTouch) { + Assertions.checkStateNotNull(controller); + this.controllerHideOnTouch = controllerHideOnTouch; + updateContentDescription(); + } + + /** + * Returns whether the playback controls are automatically shown when playback starts, pauses, + * ends, or fails. If set to false, the playback controls can be manually operated with {@link + * #showController()} and {@link #hideController()}. + */ + public boolean getControllerAutoShow() { + return controllerAutoShow; + } + + /** + * Sets whether the playback controls are automatically shown when playback starts, pauses, ends, + * or fails. If set to false, the playback controls can be manually operated with {@link + * #showController()} and {@link #hideController()}. + * + * @param controllerAutoShow Whether the playback controls are allowed to show automatically. + */ + public void setControllerAutoShow(boolean controllerAutoShow) { + this.controllerAutoShow = controllerAutoShow; + } + + /** + * Sets whether the playback controls are hidden when ads are playing. Controls are always shown + * during ads if they are enabled and the player is paused. + * + * @param controllerHideDuringAds Whether the playback controls are hidden when ads are playing. + */ + public void setControllerHideDuringAds(boolean controllerHideDuringAds) { + this.controllerHideDuringAds = controllerHideDuringAds; + } + + /** + * Set the {@link StyledPlayerControlView.VisibilityListener}. + * + * @param listener The listener to be notified about visibility changes, or null to remove the + * current listener. + */ + public void setControllerVisibilityListener( + @Nullable StyledPlayerControlView.VisibilityListener listener) { + Assertions.checkStateNotNull(controller); + if (this.controllerVisibilityListener == listener) { + return; + } + if (this.controllerVisibilityListener != null) { + controller.removeVisibilityListener(this.controllerVisibilityListener); + } + this.controllerVisibilityListener = listener; + if (listener != null) { + controller.addVisibilityListener(listener); + } + } + + /** + * Sets the {@link StyledPlayerControlView.OnFullScreenModeChangedListener}. + * + * @param listener The listener to be notified when the fullscreen button is clicked, or null to + * remove the current listener and hide the fullscreen button. + */ + public void setControllerOnFullScreenModeChangedListener( + @Nullable StyledPlayerControlView.OnFullScreenModeChangedListener listener) { + Assertions.checkStateNotNull(controller); + controller.setOnFullScreenModeChangedListener(listener); + } + + /** + * Sets the {@link PlaybackPreparer}. + * + * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback + * preparer. + */ + public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { + Assertions.checkStateNotNull(controller); + controller.setPlaybackPreparer(playbackPreparer); + } + + /** + * Sets the {@link ControlDispatcher}. + * + * @param controlDispatcher The {@link ControlDispatcher}. + */ + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + Assertions.checkStateNotNull(controller); + controller.setControlDispatcher(controlDispatcher); + } + + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + Assertions.checkStateNotNull(controller); + controller.setShowRewindButton(showRewindButton); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + Assertions.checkStateNotNull(controller); + controller.setShowFastForwardButton(showFastForwardButton); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + Assertions.checkStateNotNull(controller); + controller.setShowPreviousButton(showPreviousButton); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + Assertions.checkStateNotNull(controller); + controller.setShowNextButton(showNextButton); + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setRewindIncrementMs(int rewindMs) { + Assertions.checkStateNotNull(controller); + controller.setRewindIncrementMs(rewindMs); + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setFastForwardIncrementMs(int fastForwardMs) { + Assertions.checkStateNotNull(controller); + controller.setFastForwardIncrementMs(fastForwardMs); + } + + /** + * Sets which repeat toggle modes are enabled. + * + * @param repeatToggleModes A set of {@link RepeatModeUtil.RepeatToggleModes}. + */ + public void setRepeatToggleModes(@RepeatModeUtil.RepeatToggleModes int repeatToggleModes) { + Assertions.checkStateNotNull(controller); + controller.setRepeatToggleModes(repeatToggleModes); + } + + /** + * Sets whether the shuffle button is shown. + * + * @param showShuffleButton Whether the shuffle button is shown. + */ + public void setShowShuffleButton(boolean showShuffleButton) { + Assertions.checkStateNotNull(controller); + controller.setShowShuffleButton(showShuffleButton); + } + + /** + * Sets whether the subtitle button is shown. + * + * @param showSubtitleButton Whether the subtitle button is shown. + */ + public void setShowSubtitleButton(boolean showSubtitleButton) { + Assertions.checkStateNotNull(controller); + controller.setShowSubtitleButton(showSubtitleButton); + } + + /** + * Sets whether the vr button is shown. + * + * @param showVrButton Whether the vr button is shown. + */ + public void setShowVrButton(boolean showVrButton) { + Assertions.checkStateNotNull(controller); + controller.setShowVrButton(showVrButton); + } + + /** + * Sets whether the time bar should show all windows, as opposed to just the current one. + * + * @param showMultiWindowTimeBar Whether to show all windows. + */ + public void setShowMultiWindowTimeBar(boolean showMultiWindowTimeBar) { + Assertions.checkStateNotNull(controller); + controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar); + } + + /** + * Sets the millisecond positions of extra ad markers relative to the start of the window (or + * timeline, if in multi-window mode) and whether each extra ad has been played or not. The + * markers are shown in addition to any ad markers for ads in the player's timeline. + * + * @param extraAdGroupTimesMs The millisecond timestamps of the extra ad markers to show, or + * {@code null} to show no extra ad markers. + * @param extraPlayedAdGroups Whether each ad has been played, or {@code null} to show no extra ad + * markers. + */ + public void setExtraAdGroupMarkers( + @Nullable long[] extraAdGroupTimesMs, @Nullable boolean[] extraPlayedAdGroups) { + Assertions.checkStateNotNull(controller); + controller.setExtraAdGroupMarkers(extraAdGroupTimesMs, extraPlayedAdGroups); + } + + /** + * Set the {@link AspectRatioFrameLayout.AspectRatioListener}. + * + * @param listener The listener to be notified about aspect ratios changes of the video content or + * the content frame. + */ + public void setAspectRatioListener( + @Nullable AspectRatioFrameLayout.AspectRatioListener listener) { + Assertions.checkStateNotNull(contentFrame); + contentFrame.setAspectRatioListener(listener); + } + + /** + * Gets the view onto which video is rendered. This is a: + * + *

            + *
          • {@link SurfaceView} by default, or if the {@code surface_type} attribute is set to {@code + * surface_view}. + *
          • {@link TextureView} if {@code surface_type} is {@code texture_view}. + *
          • {@link SphericalGLSurfaceView} if {@code surface_type} is {@code + * spherical_gl_surface_view}. + *
          • {@link VideoDecoderGLSurfaceView} if {@code surface_type} is {@code + * video_decoder_gl_surface_view}. + *
          • {@code null} if {@code surface_type} is {@code none}. + *
          + * + * @return The {@link SurfaceView}, {@link TextureView}, {@link SphericalGLSurfaceView}, {@link + * VideoDecoderGLSurfaceView} or {@code null}. + */ + @Nullable + public View getVideoSurfaceView() { + return surfaceView; + } + + /** + * Gets the overlay {@link FrameLayout}, which can be populated with UI elements to show on top of + * the player. + * + * @return The overlay {@link FrameLayout}, or {@code null} if the layout has been customized and + * the overlay is not present. + */ + @Nullable + public FrameLayout getOverlayFrameLayout() { + return overlayFrameLayout; + } + + /** + * Gets the {@link SubtitleView}. + * + * @return The {@link SubtitleView}, or {@code null} if the layout has been customized and the + * subtitle view is not present. + */ + @Nullable + public SubtitleView getSubtitleView() { + return subtitleView; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!useController() || player == null) { + return false; + } + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isTouching = true; + return true; + case MotionEvent.ACTION_UP: + if (isTouching) { + isTouching = false; + return performClick(); + } + return false; + default: + return false; + } + } + + @Override + public boolean performClick() { + super.performClick(); + return toggleControllerVisibility(); + } + + @Override + public boolean onTrackballEvent(MotionEvent ev) { + if (!useController() || player == null) { + return false; + } + maybeShowController(true); + return true; + } + + /** + * Should be called when the player is visible to the user and if {@code surface_type} is {@code + * spherical_gl_surface_view}. It is the counterpart to {@link #onPause()}. + * + *

          This method should typically be called in {@code Activity.onStart()}, or {@code + * Activity.onResume()} for API versions <= 23. + */ + public void onResume() { + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).onResume(); + } + } + + /** + * Should be called when the player is no longer visible to the user and if {@code surface_type} + * is {@code spherical_gl_surface_view}. It is the counterpart to {@link #onResume()}. + * + *

          This method should typically be called in {@code Activity.onStop()}, or {@code + * Activity.onPause()} for API versions <= 23. + */ + public void onPause() { + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).onPause(); + } + } + + /** + * Called when there's a change in the aspect ratio of the content being displayed. The default + * implementation sets the aspect ratio of the content frame to that of the content, unless the + * content view is a {@link SphericalGLSurfaceView} in which case the frame's aspect ratio is + * cleared. + * + * @param contentAspectRatio The aspect ratio of the content. + * @param contentFrame The content frame, or {@code null}. + * @param contentView The view that holds the content being displayed, or {@code null}. + */ + protected void onContentAspectRatioChanged( + float contentAspectRatio, + @Nullable AspectRatioFrameLayout contentFrame, + @Nullable View contentView) { + if (contentFrame != null) { + contentFrame.setAspectRatio( + contentView instanceof SphericalGLSurfaceView ? 0 : contentAspectRatio); + } + } + + // AdsLoader.AdViewProvider implementation. + + @Override + public ViewGroup getAdViewGroup() { + return Assertions.checkStateNotNull( + adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback"); + } + + @Override + public View[] getAdOverlayViews() { + ArrayList overlayViews = new ArrayList<>(); + if (overlayFrameLayout != null) { + overlayViews.add(overlayFrameLayout); + } + if (controller != null) { + overlayViews.add(controller); + } + return overlayViews.toArray(new View[0]); + } + + // Internal methods. + + @EnsuresNonNullIf(expression = "controller", result = true) + private boolean useController() { + if (useController) { + Assertions.checkStateNotNull(controller); + return true; + } + return false; + } + + @EnsuresNonNullIf(expression = "artworkView", result = true) + private boolean useArtwork() { + if (useArtwork) { + Assertions.checkStateNotNull(artworkView); + return true; + } + return false; + } + + private boolean toggleControllerVisibility() { + if (!useController() || player == null) { + return false; + } + if (!controller.isFullyVisible()) { + maybeShowController(true); + return true; + } else if (controllerHideOnTouch) { + controller.hide(); + return true; + } + return false; + } + + /** Shows the playback controls, but only if forced or shown indefinitely. */ + private void maybeShowController(boolean isForced) { + if (isPlayingAd() && controllerHideDuringAds) { + return; + } + if (useController()) { + boolean wasShowingIndefinitely = + controller.isFullyVisible() && controller.getShowTimeoutMs() <= 0; + boolean shouldShowIndefinitely = shouldShowControllerIndefinitely(); + if (isForced || wasShowingIndefinitely || shouldShowIndefinitely) { + showController(shouldShowIndefinitely); + } + } + } + + private boolean shouldShowControllerIndefinitely() { + if (player == null) { + return true; + } + int playbackState = player.getPlaybackState(); + return controllerAutoShow + && (playbackState == Player.STATE_IDLE + || playbackState == Player.STATE_ENDED + || !player.getPlayWhenReady()); + } + + private void showController(boolean showIndefinitely) { + if (!useController()) { + return; + } + controller.setShowTimeoutMs(showIndefinitely ? 0 : controllerShowTimeoutMs); + controller.show(); + } + + private boolean isPlayingAd() { + return player != null && player.isPlayingAd() && player.getPlayWhenReady(); + } + + private void updateForCurrentTrackSelections(boolean isNewPlayer) { + @Nullable Player player = this.player; + if (player == null || player.getCurrentTrackGroups().isEmpty()) { + if (!keepContentOnPlayerReset) { + hideArtwork(); + closeShutter(); + } + return; + } + + if (isNewPlayer && !keepContentOnPlayerReset) { + // Hide any video from the previous player. + closeShutter(); + } + + TrackSelectionArray selections = player.getCurrentTrackSelections(); + for (int i = 0; i < selections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_VIDEO && selections.get(i) != null) { + // Video enabled so artwork must be hidden. If the shutter is closed, it will be opened in + // onRenderedFirstFrame(). + hideArtwork(); + return; + } + } + + // Video disabled so the shutter must be closed. + closeShutter(); + // Display artwork if enabled and available, else hide it. + if (useArtwork()) { + for (int i = 0; i < selections.length; i++) { + @Nullable TrackSelection selection = selections.get(i); + if (selection != null) { + for (int j = 0; j < selection.length(); j++) { + @Nullable Metadata metadata = selection.getFormat(j).metadata; + if (metadata != null && setArtworkFromMetadata(metadata)) { + return; + } + } + } + } + if (setDrawableArtwork(defaultArtwork)) { + return; + } + } + // Artwork disabled or unavailable. + hideArtwork(); + } + + @RequiresNonNull("artworkView") + private boolean setArtworkFromMetadata(Metadata metadata) { + boolean isArtworkSet = false; + int currentPictureType = PICTURE_TYPE_NOT_SET; + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry metadataEntry = metadata.get(i); + int pictureType; + byte[] bitmapData; + if (metadataEntry instanceof ApicFrame) { + bitmapData = ((ApicFrame) metadataEntry).pictureData; + pictureType = ((ApicFrame) metadataEntry).pictureType; + } else if (metadataEntry instanceof PictureFrame) { + bitmapData = ((PictureFrame) metadataEntry).pictureData; + pictureType = ((PictureFrame) metadataEntry).pictureType; + } else { + continue; + } + // Prefer the first front cover picture. If there aren't any, prefer the first picture. + if (currentPictureType == PICTURE_TYPE_NOT_SET || pictureType == PICTURE_TYPE_FRONT_COVER) { + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length); + isArtworkSet = setDrawableArtwork(new BitmapDrawable(getResources(), bitmap)); + currentPictureType = pictureType; + if (currentPictureType == PICTURE_TYPE_FRONT_COVER) { + break; + } + } + } + return isArtworkSet; + } + + @RequiresNonNull("artworkView") + private boolean setDrawableArtwork(@Nullable Drawable drawable) { + if (drawable != null) { + int drawableWidth = drawable.getIntrinsicWidth(); + int drawableHeight = drawable.getIntrinsicHeight(); + if (drawableWidth > 0 && drawableHeight > 0) { + float artworkAspectRatio = (float) drawableWidth / drawableHeight; + onContentAspectRatioChanged(artworkAspectRatio, contentFrame, artworkView); + artworkView.setImageDrawable(drawable); + artworkView.setVisibility(VISIBLE); + return true; + } + } + return false; + } + + private void hideArtwork() { + if (artworkView != null) { + artworkView.setImageResource(android.R.color.transparent); // Clears any bitmap reference. + artworkView.setVisibility(INVISIBLE); + } + } + + private void closeShutter() { + if (shutterView != null) { + shutterView.setVisibility(View.VISIBLE); + } + } + + private void updateBuffering() { + if (bufferingView != null) { + boolean showBufferingSpinner = + player != null + && player.getPlaybackState() == Player.STATE_BUFFERING + && (showBuffering == SHOW_BUFFERING_ALWAYS + || (showBuffering == SHOW_BUFFERING_WHEN_PLAYING && player.getPlayWhenReady())); + bufferingView.setVisibility(showBufferingSpinner ? View.VISIBLE : View.GONE); + } + } + + private void updateErrorMessage() { + if (errorMessageView != null) { + if (customErrorMessage != null) { + errorMessageView.setText(customErrorMessage); + errorMessageView.setVisibility(View.VISIBLE); + return; + } + @Nullable ExoPlaybackException error = player != null ? player.getPlayerError() : null; + if (error != null && errorMessageProvider != null) { + CharSequence errorMessage = errorMessageProvider.getErrorMessage(error).second; + errorMessageView.setText(errorMessage); + errorMessageView.setVisibility(View.VISIBLE); + } else { + errorMessageView.setVisibility(View.GONE); + } + } + } + + private void updateContentDescription() { + if (controller == null || !useController) { + setContentDescription(/* contentDescription= */ null); + } else if (controller.isFullyVisible()) { + setContentDescription( + /* contentDescription= */ controllerHideOnTouch + ? getResources().getString(R.string.exo_controls_hide) + : null); + } else { + setContentDescription( + /* contentDescription= */ getResources().getString(R.string.exo_controls_show)); + } + } + + private void updateControllerVisibility() { + if (isPlayingAd() && controllerHideDuringAds) { + hideController(); + } else { + maybeShowController(false); + } + } + + @RequiresApi(23) + private static void configureEditModeLogoV23(Resources resources, ImageView logo) { + logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo, null)); + logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color, null)); + } + + private static void configureEditModeLogo(Resources resources, ImageView logo) { + logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo)); + logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color)); + } + + @SuppressWarnings("ResourceType") + private static void setResizeModeRaw(AspectRatioFrameLayout aspectRatioFrame, int resizeMode) { + aspectRatioFrame.setResizeMode(resizeMode); + } + + /** Applies a texture rotation to a {@link TextureView}. */ + private static void applyTextureViewRotation(TextureView textureView, int textureViewRotation) { + Matrix transformMatrix = new Matrix(); + float textureViewWidth = textureView.getWidth(); + float textureViewHeight = textureView.getHeight(); + if (textureViewWidth != 0 && textureViewHeight != 0 && textureViewRotation != 0) { + float pivotX = textureViewWidth / 2; + float pivotY = textureViewHeight / 2; + transformMatrix.postRotate(textureViewRotation, pivotX, pivotY); + + // After rotation, scale the rotated texture to fit the TextureView size. + RectF originalTextureRect = new RectF(0, 0, textureViewWidth, textureViewHeight); + RectF rotatedTextureRect = new RectF(); + transformMatrix.mapRect(rotatedTextureRect, originalTextureRect); + transformMatrix.postScale( + textureViewWidth / rotatedTextureRect.width(), + textureViewHeight / rotatedTextureRect.height(), + pivotX, + pivotY); + } + textureView.setTransform(transformMatrix); + } + + @SuppressLint("InlinedApi") + private boolean isDpadKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_UP_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_UP_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER; + } + + private final class ComponentListener + implements Player.EventListener, + TextOutput, + VideoListener, + OnLayoutChangeListener, + SingleTapListener, + StyledPlayerControlView.VisibilityListener { + + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + + // TextOutput implementation + + @Override + public void onCues(List cues) { + if (subtitleView != null) { + subtitleView.onCues(cues); + } + } + + // VideoListener implementation + + @Override + public void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + float videoAspectRatio = + (height == 0 || width == 0) ? 1 : (width * pixelWidthHeightRatio) / height; + + if (surfaceView instanceof TextureView) { + // Try to apply rotation transformation when our surface is a TextureView. + if (unappliedRotationDegrees == 90 || unappliedRotationDegrees == 270) { + // We will apply a rotation 90/270 degree to the output texture of the TextureView. + // In this case, the output video's width and height will be swapped. + videoAspectRatio = 1 / videoAspectRatio; + } + if (textureViewRotation != 0) { + surfaceView.removeOnLayoutChangeListener(this); + } + textureViewRotation = unappliedRotationDegrees; + if (textureViewRotation != 0) { + // The texture view's dimensions might be changed after layout step. + // So add an OnLayoutChangeListener to apply rotation after layout step. + surfaceView.addOnLayoutChangeListener(this); + } + applyTextureViewRotation((TextureView) surfaceView, textureViewRotation); + } + + onContentAspectRatioChanged(videoAspectRatio, contentFrame, surfaceView); + } + + @Override + public void onRenderedFirstFrame() { + if (shutterView != null) { + shutterView.setVisibility(INVISIBLE); + } + } + + @Override + public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + Player player = Assertions.checkNotNull(StyledPlayerView.this.player); + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + lastPeriodUidWithTracks = null; + } else if (!player.getCurrentTrackGroups().isEmpty()) { + lastPeriodUidWithTracks = + timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid; + } else if (lastPeriodUidWithTracks != null) { + int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks); + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + int lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex; + if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) { + // We're in the same window. Suppress the update. + return; + } + } + lastPeriodUidWithTracks = null; + } + + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + + // Player.EventListener implementation + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + updateBuffering(); + updateErrorMessage(); + updateControllerVisibility(); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + updateBuffering(); + updateControllerVisibility(); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + if (isPlayingAd() && controllerHideDuringAds) { + hideController(); + } + } + + // OnLayoutChangeListener implementation + + @Override + public void onLayoutChange( + View view, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + applyTextureViewRotation((TextureView) view, textureViewRotation); + } + + // SingleTapListener implementation + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return toggleControllerVisibility(); + } + + // StyledPlayerControlView.VisibilityListener implementation + + @Override + public void onVisibilityChange(int visibility) { + updateContentDescription(); + } + } +} diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml new file mode 100644 index 0000000000..5e4dd5550f --- /dev/null +++ b/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml b/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml new file mode 100644 index 0000000000..ee43206b4a --- /dev/null +++ b/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml b/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml new file mode 100644 index 0000000000..7ee298e357 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_check.xml b/library/ui/src/main/res/drawable/exo_ic_check.xml new file mode 100644 index 0000000000..ad5d63ac5c --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_check.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml b/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml new file mode 100644 index 0000000000..d614a9e2f2 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml @@ -0,0 +1,24 @@ + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml b/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml new file mode 100644 index 0000000000..9b25426dd1 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml @@ -0,0 +1,24 @@ + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml b/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml new file mode 100644 index 0000000000..d95f42ab3d --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_forward.xml b/library/ui/src/main/res/drawable/exo_ic_forward.xml new file mode 100644 index 0000000000..11470d4d4a --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_forward.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_forward_30.xml b/library/ui/src/main/res/drawable/exo_ic_forward_30.xml new file mode 100644 index 0000000000..b55831ec57 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_forward_30.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml b/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml new file mode 100644 index 0000000000..f0faf4d025 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml b/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml new file mode 100644 index 0000000000..73d35277a3 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_launch.xml b/library/ui/src/main/res/drawable/exo_ic_launch.xml new file mode 100644 index 0000000000..1646f2a0e4 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_launch.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml b/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml new file mode 100644 index 0000000000..6789374094 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml b/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml new file mode 100644 index 0000000000..f00f85f543 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml b/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml new file mode 100644 index 0000000000..e57acb5db6 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_rewind.xml b/library/ui/src/main/res/drawable/exo_ic_rewind.xml new file mode 100644 index 0000000000..2c741f7224 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_rewind.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml b/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml new file mode 100644 index 0000000000..942fe5b76f --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_settings.xml b/library/ui/src/main/res/drawable/exo_ic_settings.xml new file mode 100644 index 0000000000..2dab2c0f17 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_settings.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_skip_next.xml b/library/ui/src/main/res/drawable/exo_ic_skip_next.xml new file mode 100644 index 0000000000..183434e864 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_skip_next.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml b/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml new file mode 100644 index 0000000000..363b94f3dc --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_speed.xml b/library/ui/src/main/res/drawable/exo_ic_speed.xml new file mode 100644 index 0000000000..fd1fd8e1d5 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_speed.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml b/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml new file mode 100644 index 0000000000..ea6819eb3a --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml b/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml new file mode 100644 index 0000000000..b1d36cde79 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_progress.xml b/library/ui/src/main/res/drawable/exo_progress.xml new file mode 100644 index 0000000000..2ba05326f0 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_progress.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_progress_thumb.xml b/library/ui/src/main/res/drawable/exo_progress_thumb.xml new file mode 100644 index 0000000000..e61a015f7d --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_progress_thumb.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml new file mode 100644 index 0000000000..9f7e1fd027 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_rew.xml b/library/ui/src/main/res/drawable/exo_ripple_rew.xml new file mode 100644 index 0000000000..5562b1352c --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ripple_rew.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_title_bar_gradient.xml b/library/ui/src/main/res/drawable/exo_title_bar_gradient.xml new file mode 100644 index 0000000000..fd3b0745fe --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_title_bar_gradient.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/library/ui/src/main/res/font/roboto_medium_numbers.ttf b/library/ui/src/main/res/font/roboto_medium_numbers.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b61ac79ddf1fb7fe0b90370806f6da05ac2ef8b3 GIT binary patch literal 3316 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 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml new file mode 100644 index 0000000000..dafe7d9585 --- /dev/null +++ b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml @@ -0,0 +1,28 @@ + + + + + +

        - *
      • {@code disable_animation} - Whether animation is applied when hide and show - * controls. + *
      • {@code animation_enabled} - Whether an animation is used to show and hide the + * playback controls. *
          - *
        • Corresponding method: None - *
        • Default: false + *
        • Corresponding method: {@link #setAnimationEnabled(boolean)} + *
        • Default: true *
        *
      • {@code time_bar_min_update_interval} - Specifies the minimum interval between time * bar position updates. @@ -471,8 +471,7 @@ public class StyledPlayerControlView extends FrameLayout { showNextButton = true; showShuffleButton = false; showSubtitleButton = false; - boolean disableAnimation = false; - + boolean animationEnabled = true; boolean showVrButton = false; if (playbackAttrs != null) { @@ -512,15 +511,15 @@ public class StyledPlayerControlView extends FrameLayout { a.getInt( R.styleable.StyledPlayerControlView_time_bar_min_update_interval, timeBarMinUpdateIntervalMs)); - disableAnimation = - a.getBoolean(R.styleable.StyledPlayerControlView_disable_animation, disableAnimation); + animationEnabled = + a.getBoolean(R.styleable.StyledPlayerControlView_animation_enabled, animationEnabled); } finally { a.recycle(); } } controlViewLayoutManager = new StyledPlayerControlViewLayoutManager(); - controlViewLayoutManager.setDisableAnimation(disableAnimation); + controlViewLayoutManager.setAnimationEnabled(animationEnabled); visibilityListeners = new CopyOnWriteArrayList<>(); period = new Timeline.Period(); window = new Timeline.Window(); @@ -1031,6 +1030,20 @@ public class StyledPlayerControlView extends FrameLayout { } } + /** + * Sets whether an animation is used to show and hide the playback controls. + * + * @param animationEnabled Whether an animation is applied to show and hide playback controls. + */ + public void setAnimationEnabled(boolean animationEnabled) { + controlViewLayoutManager.setAnimationEnabled(animationEnabled); + } + + /** Returns whether an animation is used to show and hide the playback controls. */ + public boolean isAnimationEnabled() { + return controlViewLayoutManager.isAnimationEnabled(); + } + /** * Sets the minimum interval between time bar position updates. * diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java index ef89023406..d7ec1abc9d 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java @@ -50,7 +50,7 @@ import java.util.ArrayList; private int uxState = UX_STATE_ALL_VISIBLE; private boolean isMinimalMode; private boolean needToShowBars; - private boolean disableAnimation = false; + private boolean animationEnabled = true; @Nullable private StyledPlayerControlView styledPlayerControlView; @@ -93,7 +93,7 @@ import java.util.ArrayList; return; } removeHideCallbacks(); - if (isAnimationDisabled()) { + if (!animationEnabled) { postDelayedRunnable(hideController, 0); } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { postDelayedRunnable(hideProgressBar, 0); @@ -102,8 +102,12 @@ import java.util.ArrayList; } } - void setDisableAnimation(boolean disableAnimation) { - this.disableAnimation = disableAnimation; + void setAnimationEnabled(boolean animationEnabled) { + this.animationEnabled = animationEnabled; + } + + boolean isAnimationEnabled() { + return animationEnabled; } void resetHideCallbacks() { @@ -114,7 +118,7 @@ import java.util.ArrayList; int showTimeoutMs = styledPlayerControlView != null ? styledPlayerControlView.getShowTimeoutMs() : 0; if (showTimeoutMs > 0) { - if (isAnimationDisabled()) { + if (!animationEnabled) { postDelayedRunnable(hideController, showTimeoutMs); } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); @@ -428,15 +432,11 @@ import java.util.ArrayList; } } - private boolean isAnimationDisabled() { - return disableAnimation; - } - private final Runnable showAllBars = new Runnable() { @Override public void run() { - if (isAnimationDisabled()) { + if (!animationEnabled) { setUxState(UX_STATE_ALL_VISIBLE); resetHideCallbacks(); return; diff --git a/library/ui/src/main/res/values/attrs.xml b/library/ui/src/main/res/values/attrs.xml index 01db368847..439afb19c2 100644 --- a/library/ui/src/main/res/values/attrs.xml +++ b/library/ui/src/main/res/values/attrs.xml @@ -70,7 +70,7 @@ - + @@ -151,7 +151,7 @@ - + @@ -214,7 +214,7 @@ - + From 7d46be5564a63c46e1e0ea5d8d92203e79427e72 Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 1 Jul 2020 09:43:16 +0100 Subject: [PATCH 0575/1052] Do not use no codec passthrough with a sourceDrmSession PiperOrigin-RevId: 319183486 --- .../android/exoplayer2/mediacodec/MediaCodecRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index ce56c115f9..a72aed3e75 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -550,7 +550,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return; } - if (inputFormat.drmInitData == null && usePassthrough(inputFormat)) { + if (sourceDrmSession == null && usePassthrough(inputFormat)) { initPassthrough(inputFormat); return; } From e4e743a35f001ebfae6eef69de08a66432a0da10 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 1 Jul 2020 09:44:49 +0100 Subject: [PATCH 0576/1052] Fix remaining common module nullness issues. PiperOrigin-RevId: 319183621 --- .../exoplayer2/ext/opus/OpusDecoder.java | 34 ++++++++---- .../exoplayer2/ext/vp9/VpxDecoder.java | 23 +++++--- .../com/google/android/exoplayer2/Format.java | 3 +- .../exoplayer2/decoder/CryptoInfo.java | 14 ++--- .../exoplayer2/util/CopyOnWriteMultiset.java | 4 +- .../android/exoplayer2/util/NalUnitUtil.java | 54 +++++++++---------- .../google/android/exoplayer2/util/Util.java | 5 ++ .../exoplayer2/util/NalUnitUtilTest.java | 10 ++-- .../AsynchronousMediaCodecBufferEnqueuer.java | 15 +++--- 9 files changed, 100 insertions(+), 62 deletions(-) diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java index c82636ca5a..e082ab46b1 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; import com.google.android.exoplayer2.drm.DecryptionException; import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -166,13 +167,28 @@ import java.util.List; } ByteBuffer inputData = Util.castNonNull(inputBuffer.data); CryptoInfo cryptoInfo = inputBuffer.cryptoInfo; - int result = inputBuffer.isEncrypted() - ? opusSecureDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), - outputBuffer, SAMPLE_RATE, exoMediaCrypto, cryptoInfo.mode, - cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples, - cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData) - : opusDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), - outputBuffer); + int result = + inputBuffer.isEncrypted() + ? opusSecureDecode( + nativeDecoderContext, + inputBuffer.timeUs, + inputData, + inputData.limit(), + outputBuffer, + SAMPLE_RATE, + exoMediaCrypto, + cryptoInfo.mode, + Assertions.checkNotNull(cryptoInfo.key), + Assertions.checkNotNull(cryptoInfo.iv), + cryptoInfo.numSubSamples, + cryptoInfo.numBytesOfClearData, + cryptoInfo.numBytesOfEncryptedData) + : opusDecode( + nativeDecoderContext, + inputBuffer.timeUs, + inputData, + inputData.limit(), + outputBuffer); if (result < 0) { if (result == DRM_ERROR) { String message = "Drm error: " + opusGetErrorMessage(nativeDecoderContext); @@ -253,8 +269,8 @@ import java.util.List; byte[] key, byte[] iv, int numSubSamples, - int[] numBytesOfClearData, - int[] numBytesOfEncryptedData); + @Nullable int[] numBytesOfClearData, + @Nullable int[] numBytesOfEncryptedData); private native void opusClose(long decoder); private native void opusReset(long decoder); diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java index 22086cd74d..ce0873ad40 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java @@ -124,11 +124,20 @@ import java.nio.ByteBuffer; ByteBuffer inputData = Util.castNonNull(inputBuffer.data); int inputSize = inputData.limit(); CryptoInfo cryptoInfo = inputBuffer.cryptoInfo; - final long result = inputBuffer.isEncrypted() - ? vpxSecureDecode(vpxDecContext, inputData, inputSize, exoMediaCrypto, - cryptoInfo.mode, cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples, - cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData) - : vpxDecode(vpxDecContext, inputData, inputSize); + final long result = + inputBuffer.isEncrypted() + ? vpxSecureDecode( + vpxDecContext, + inputData, + inputSize, + exoMediaCrypto, + cryptoInfo.mode, + Assertions.checkNotNull(cryptoInfo.key), + Assertions.checkNotNull(cryptoInfo.iv), + cryptoInfo.numSubSamples, + cryptoInfo.numBytesOfClearData, + cryptoInfo.numBytesOfEncryptedData) + : vpxDecode(vpxDecContext, inputData, inputSize); if (result != NO_ERROR) { if (result == DRM_ERROR) { String message = "Drm error: " + vpxGetErrorMessage(vpxDecContext); @@ -207,8 +216,8 @@ import java.nio.ByteBuffer; byte[] key, byte[] iv, int numSubSamples, - int[] numBytesOfClearData, - int[] numBytesOfEncryptedData); + @Nullable int[] numBytesOfClearData, + @Nullable int[] numBytesOfEncryptedData); private native int vpxGetFrame(long context, VideoDecoderOutputBuffer outputBuffer); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Format.java b/library/common/src/main/java/com/google/android/exoplayer2/Format.java index 6ff41d0c72..6053dbf77e 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Format.java @@ -21,6 +21,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.ColorInfo; @@ -1309,7 +1310,7 @@ public final class Format implements Parcelable { int initializationDataSize = in.readInt(); initializationData = new ArrayList<>(initializationDataSize); for (int i = 0; i < initializationDataSize; i++) { - initializationData.add(in.createByteArray()); + initializationData.add(Assertions.checkNotNull(in.createByteArray())); } drmInitData = in.readParcelable(DrmInitData.class.getClassLoader()); subsampleOffsetUs = in.readLong(); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java index 1c52abc476..7eaab6ae1d 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.decoder; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; /** @@ -30,13 +32,13 @@ public final class CryptoInfo { * * @see android.media.MediaCodec.CryptoInfo#iv */ - public byte[] iv; + @Nullable public byte[] iv; /** * The 16 byte key id. * * @see android.media.MediaCodec.CryptoInfo#key */ - public byte[] key; + @Nullable public byte[] key; /** * The type of encryption that has been applied. Must be one of the {@link C.CryptoMode} values. * @@ -49,14 +51,14 @@ public final class CryptoInfo { * * @see android.media.MediaCodec.CryptoInfo#numBytesOfClearData */ - public int[] numBytesOfClearData; + @Nullable public int[] numBytesOfClearData; /** * The number of trailing encrypted bytes in each sub-sample. If null, all bytes are treated as * clear and {@link #numBytesOfClearData} must be specified. * * @see android.media.MediaCodec.CryptoInfo#numBytesOfEncryptedData */ - public int[] numBytesOfEncryptedData; + @Nullable public int[] numBytesOfEncryptedData; /** * The number of subSamples that make up the buffer's contents. * @@ -73,7 +75,7 @@ public final class CryptoInfo { public int clearBlocks; private final android.media.MediaCodec.CryptoInfo frameworkCryptoInfo; - private final PatternHolderV24 patternHolder; + @Nullable private final PatternHolderV24 patternHolder; public CryptoInfo() { frameworkCryptoInfo = new android.media.MediaCodec.CryptoInfo(); @@ -102,7 +104,7 @@ public final class CryptoInfo { frameworkCryptoInfo.iv = iv; frameworkCryptoInfo.mode = mode; if (Util.SDK_INT >= 24) { - patternHolder.set(encryptedBlocks, clearBlocks); + Assertions.checkNotNull(patternHolder).set(encryptedBlocks, clearBlocks); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java b/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java index e8eb0d0df9..505ff55cbe 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java @@ -41,7 +41,9 @@ import java.util.Set; * * @param The type of element being stored. */ -public final class CopyOnWriteMultiset implements Iterable { +// Intentionally extending @NonNull-by-default Object to disallow @Nullable E types. +@SuppressWarnings("TypeParameterExplicitlyExtendsObject") +public final class CopyOnWriteMultiset implements Iterable { private final Object lock; diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java index 05585d5301..6edbecf1ea 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java @@ -431,18 +431,18 @@ public final class NalUnitUtil { return endOffset; } - if (prefixFlags != null) { - if (prefixFlags[0]) { - clearPrefixFlags(prefixFlags); - return startOffset - 3; - } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1) { - clearPrefixFlags(prefixFlags); - return startOffset - 2; - } else if (length > 2 && prefixFlags[2] && data[startOffset] == 0 - && data[startOffset + 1] == 1) { - clearPrefixFlags(prefixFlags); - return startOffset - 1; - } + if (prefixFlags[0]) { + clearPrefixFlags(prefixFlags); + return startOffset - 3; + } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1) { + clearPrefixFlags(prefixFlags); + return startOffset - 2; + } else if (length > 2 + && prefixFlags[2] + && data[startOffset] == 0 + && data[startOffset + 1] == 1) { + clearPrefixFlags(prefixFlags); + return startOffset - 1; } int limit = endOffset - 1; @@ -453,9 +453,7 @@ public final class NalUnitUtil { // There isn't a NAL prefix here, or at the next two positions. Do nothing and let the // loop advance the index by three. } else if (data[i - 2] == 0 && data[i - 1] == 0 && data[i] == 1) { - if (prefixFlags != null) { - clearPrefixFlags(prefixFlags); - } + clearPrefixFlags(prefixFlags); return i - 2; } else { // There isn't a NAL prefix here, but there might be at the next position. We should @@ -464,18 +462,20 @@ public final class NalUnitUtil { } } - if (prefixFlags != null) { - // True if the last three bytes in the data seen so far are {0,0,1}. - prefixFlags[0] = length > 2 - ? (data[endOffset - 3] == 0 && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) - : length == 2 ? (prefixFlags[2] && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) - : (prefixFlags[1] && data[endOffset - 1] == 1); - // True if the last two bytes in the data seen so far are {0,0}. - prefixFlags[1] = length > 1 ? data[endOffset - 2] == 0 && data[endOffset - 1] == 0 - : prefixFlags[2] && data[endOffset - 1] == 0; - // True if the last byte in the data seen so far is {0}. - prefixFlags[2] = data[endOffset - 1] == 0; - } + // True if the last three bytes in the data seen so far are {0,0,1}. + prefixFlags[0] = + length > 2 + ? (data[endOffset - 3] == 0 && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) + : length == 2 + ? (prefixFlags[2] && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) + : (prefixFlags[1] && data[endOffset - 1] == 1); + // True if the last two bytes in the data seen so far are {0,0}. + prefixFlags[1] = + length > 1 + ? data[endOffset - 2] == 0 && data[endOffset - 1] == 0 + : prefixFlags[2] && data[endOffset - 1] == 0; + // True if the last byte in the data seen so far is {0}. + prefixFlags[2] = data[endOffset - 1] == 0; return endOffset; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index b9e3f86096..0afeaffdaf 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -1937,6 +1937,8 @@ public final class Util { * @param context A context to access the connectivity manager. * @return The {@link C.NetworkType} of the current network connection. */ + // Intentional null check to guard against user input. + @SuppressWarnings("known.nonnull") @C.NetworkType public static int getNetworkType(Context context) { if (context == null) { @@ -1944,6 +1946,7 @@ public final class Util { return C.NETWORK_TYPE_UNKNOWN; } NetworkInfo networkInfo; + @Nullable ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { @@ -1983,6 +1986,7 @@ public final class Util { */ public static String getCountryCode(@Nullable Context context) { if (context != null) { + @Nullable TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null) { @@ -2062,6 +2066,7 @@ public final class Util { */ public static boolean isTv(Context context) { // See https://developer.android.com/training/tv/start/hardware.html#runtime-check. + @Nullable UiModeManager uiModeManager = (UiModeManager) context.getApplicationContext().getSystemService(UI_MODE_SERVICE); return uiModeManager != null diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java index 365cff8aff..5226a42c76 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java @@ -40,19 +40,19 @@ public final class NalUnitUtilTest { byte[] data = buildTestData(); // Should find NAL unit. - int result = NalUnitUtil.findNalUnit(data, 0, data.length, null); + int result = NalUnitUtil.findNalUnit(data, 0, data.length, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION); // Should find NAL unit whose prefix ends one byte before the limit. - result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 4, null); + result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 4, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION); // Shouldn't find NAL unit whose prefix ends at the limit (since the limit is exclusive). - result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 3, null); + result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 3, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION + 3); // Should find NAL unit whose prefix starts at the offset. - result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION, data.length, null); + result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION, data.length, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION); // Shouldn't find NAL unit whose prefix starts one byte past the offset. - result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION + 1, data.length, null); + result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION + 1, data.length, new boolean[3]); assertThat(result).isEqualTo(data.length); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java index 42590eed8a..dd9a086446 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java @@ -16,11 +16,14 @@ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.media.MediaCodec; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; @@ -292,8 +295,6 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque } /** Performs a deep copy of {@code cryptoInfo} to {@code frameworkCryptoInfo}. */ - // TODO: Remove suppression [internal b/78934030]. - @SuppressWarnings("nullness:argument.type.incompatible") private static void copy( CryptoInfo cryptoInfo, android.media.MediaCodec.CryptoInfo frameworkCryptoInfo) { // Update frameworkCryptoInfo fields directly because CryptoInfo.set performs an unnecessary @@ -303,8 +304,8 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque copy(cryptoInfo.numBytesOfClearData, frameworkCryptoInfo.numBytesOfClearData); frameworkCryptoInfo.numBytesOfEncryptedData = copy(cryptoInfo.numBytesOfEncryptedData, frameworkCryptoInfo.numBytesOfEncryptedData); - frameworkCryptoInfo.key = copy(cryptoInfo.key, frameworkCryptoInfo.key); - frameworkCryptoInfo.iv = copy(cryptoInfo.iv, frameworkCryptoInfo.iv); + frameworkCryptoInfo.key = checkNotNull(copy(cryptoInfo.key, frameworkCryptoInfo.key)); + frameworkCryptoInfo.iv = checkNotNull(copy(cryptoInfo.iv, frameworkCryptoInfo.iv)); frameworkCryptoInfo.mode = cryptoInfo.mode; if (Util.SDK_INT >= 24) { android.media.MediaCodec.CryptoInfo.Pattern pattern = @@ -321,7 +322,8 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque * @param dst The destination array, which will be reused if it's at least as long as {@code src}. * @return The copy, which may be {@code dst} if it was reused. */ - private static int[] copy(int[] src, int[] dst) { + @Nullable + private static int[] copy(@Nullable int[] src, @Nullable int[] dst) { if (src == null) { return dst; } @@ -341,7 +343,8 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque * @param dst The destination array, which will be reused if it's at least as long as {@code src}. * @return The copy, which may be {@code dst} if it was reused. */ - private static byte[] copy(byte[] src, byte[] dst) { + @Nullable + private static byte[] copy(@Nullable byte[] src, @Nullable byte[] dst) { if (src == null) { return dst; } From 316f8a88cdaeff2b1b5d219097b2739ef607b2c4 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 1 Jul 2020 09:52:27 +0100 Subject: [PATCH 0577/1052] Keep DRM sessions alive for a while before fully releasing them Issue: #7011 Issue: #6725 Issue: #7066 This also mitigates (but doesn't fix) Issue: #4133 because it prevents a second key load after a short clear section. PiperOrigin-RevId: 319184325 --- RELEASENOTES.md | 4 + .../exoplayer2/drm/DefaultDrmSession.java | 32 ++- .../drm/DefaultDrmSessionManager.java | 230 ++++++++++++++---- .../drm/DefaultDrmSessionManagerTest.java | 149 +++++++++++- .../exoplayer2/testutil/FakeExoMediaDrm.java | 20 +- 5 files changed, 377 insertions(+), 58 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4fe20d018d..62ccb5e2b7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -144,6 +144,10 @@ `OfflineLicenseHelper` ([#7078](https://github.com/google/ExoPlayer/issues/7078)). * Remove generics from DRM components. + * Keep DRM sessions alive for a short time before fully releasing them + ([#7011](https://github.com/google/ExoPlayer/issues/7011), + [#6725](https://github.com/google/ExoPlayer/issues/6725), + [#7066](https://github.com/google/ExoPlayer/issues/7066)). * Downloads and caching: * Support passing an `Executor` to `DefaultDownloaderFactory` on which data downloads are performed. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index ea7994868b..5a51638c17 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -85,15 +85,26 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; void onProvisionCompleted(); } - /** Callback to be notified when the session is released. */ - public interface ReleaseCallback { + /** Callback to be notified when the reference count of this session changes. */ + public interface ReferenceCountListener { /** - * Called immediately after releasing session resources. + * Called when the internal reference count of this session is incremented. * - * @param session The session. + * @param session This session. + * @param newReferenceCount The reference count after being incremented. */ - void onSessionReleased(DefaultDrmSession session); + void onReferenceCountIncremented(DefaultDrmSession session, int newReferenceCount); + + /** + * Called when the internal reference count of this session is decremented. + * + *

        {@code newReferenceCount == 0} indicates this session is in {@link #STATE_RELEASED}. + * + * @param session This session. + * @param newReferenceCount The reference count after being decremented. + */ + void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount); } private static final String TAG = "DefaultDrmSession"; @@ -107,7 +118,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ExoMediaDrm mediaDrm; private final ProvisioningManager provisioningManager; - private final ReleaseCallback releaseCallback; + private final ReferenceCountListener referenceCountListener; private final @DefaultDrmSessionManager.Mode int mode; private final boolean playClearSamplesWithoutKeys; private final boolean isPlaceholderSession; @@ -137,7 +148,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * @param uuid The UUID of the drm scheme. * @param mediaDrm The media DRM. * @param provisioningManager The manager for provisioning. - * @param releaseCallback The {@link ReleaseCallback}. + * @param referenceCountListener The {@link ReferenceCountListener}. * @param schemeDatas DRM scheme datas for this session, or null if an {@code * offlineLicenseKeySetId} is provided or if {@code isPlaceholderSession} is true. * @param mode The DRM mode. Ignored if {@code isPlaceholderSession} is true. @@ -154,7 +165,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; UUID uuid, ExoMediaDrm mediaDrm, ProvisioningManager provisioningManager, - ReleaseCallback releaseCallback, + ReferenceCountListener referenceCountListener, @Nullable List schemeDatas, @DefaultDrmSessionManager.Mode int mode, boolean playClearSamplesWithoutKeys, @@ -170,7 +181,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } this.uuid = uuid; this.provisioningManager = provisioningManager; - this.releaseCallback = releaseCallback; + this.referenceCountListener = referenceCountListener; this.mediaDrm = mediaDrm; this.mode = mode; this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; @@ -280,6 +291,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; eventDispatcher.dispatch( DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); } + referenceCountListener.onReferenceCountIncremented(this, referenceCount); } @Override @@ -300,7 +312,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; mediaDrm.closeSession(sessionId); sessionId = null; } - releaseCallback.onSessionReleased(this); dispatchEvent(DrmSessionEventListener::onDrmSessionReleased); } if (eventDispatcher != null) { @@ -312,6 +323,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } eventDispatchers.remove(eventDispatcher); } + referenceCountListener.onReferenceCountDecremented(this, referenceCount); } // Internal methods. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 8335831ce0..2912af4c36 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -16,9 +16,11 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; +import android.media.ResourceBusyException; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -32,15 +34,18 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** A {@link DrmSessionManager} that supports playbacks using {@link ExoMediaDrm}. */ @RequiresApi(18) @@ -60,6 +65,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private int[] useDrmSessionsForClearContentTrackTypes; private boolean playClearSamplesWithoutKeys; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private long sessionKeepaliveMs; /** * Creates a builder with default values. The default values are: @@ -82,6 +88,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { exoMediaDrmProvider = FrameworkMediaDrm.DEFAULT_PROVIDER; loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); useDrmSessionsForClearContentTrackTypes = new int[0]; + sessionKeepaliveMs = DEFAULT_SESSION_KEEPALIVE_MS; } /** @@ -180,6 +187,27 @@ public class DefaultDrmSessionManager implements DrmSessionManager { return this; } + /** + * Sets the time to keep {@link DrmSession DrmSessions} alive when they're not in use. + * + *

        It can be useful to keep sessions alive during playback of short clear sections of media + * (e.g. ad breaks) to avoid opening new DRM sessions (and re-requesting keys) at the transition + * back into secure content. This assumes the secure sections before and after the clear section + * are encrypted with the same keys. + * + *

        Defaults to {@link #DEFAULT_SESSION_KEEPALIVE_MS}. Pass {@link C#TIME_UNSET} to disable + * keep-alive. + * + * @param sessionKeepaliveMs The time to keep {@link DrmSession}s alive before fully releasing, + * in milliseconds. Must be > 0 or {@link C#TIME_UNSET} to disable keep-alive. + * @return This builder. + */ + public Builder setSessionKeepaliveMs(long sessionKeepaliveMs) { + Assertions.checkArgument(sessionKeepaliveMs > 0 || sessionKeepaliveMs == C.TIME_UNSET); + this.sessionKeepaliveMs = sessionKeepaliveMs; + return this; + } + /** Builds a {@link DefaultDrmSessionManager} instance. */ public DefaultDrmSessionManager build(MediaDrmCallback mediaDrmCallback) { return new DefaultDrmSessionManager( @@ -190,7 +218,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { multiSession, useDrmSessionsForClearContentTrackTypes, playClearSamplesWithoutKeys, - loadErrorHandlingPolicy); + loadErrorHandlingPolicy, + sessionKeepaliveMs); } } @@ -232,6 +261,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { public static final int MODE_RELEASE = 3; /** Number of times to retry for initial provisioning and key request for reporting error. */ public static final int INITIAL_DRM_REQUEST_RETRY_COUNT = 3; + /** Default value for {@link Builder#setSessionKeepaliveMs(long)}. */ + public static final long DEFAULT_SESSION_KEEPALIVE_MS = 5 * 60 * C.MILLIS_PER_SECOND; private static final String TAG = "DefaultDrmSessionMgr"; @@ -244,15 +275,19 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private final boolean playClearSamplesWithoutKeys; private final ProvisioningManagerImpl provisioningManagerImpl; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final ReferenceCountListenerImpl referenceCountListener; + private final long sessionKeepaliveMs; private final List sessions; private final List provisioningSessions; + private final Set keepaliveSessions; private int prepareCallsCount; @Nullable private ExoMediaDrm exoMediaDrm; @Nullable private DefaultDrmSession placeholderDrmSession; @Nullable private DefaultDrmSession noMultiSessionDrmSession; @Nullable private Looper playbackLooper; + private @MonotonicNonNull Handler sessionReleasingHandler; private int mode; @Nullable private byte[] offlineLicenseKeySetId; @@ -336,7 +371,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { multiSession, /* useDrmSessionsForClearContentTrackTypes= */ new int[0], /* playClearSamplesWithoutKeys= */ false, - new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount)); + new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount), + DEFAULT_SESSION_KEEPALIVE_MS); } private DefaultDrmSessionManager( @@ -347,7 +383,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { boolean multiSession, int[] useDrmSessionsForClearContentTrackTypes, boolean playClearSamplesWithoutKeys, - LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + long sessionKeepaliveMs) { Assertions.checkNotNull(uuid); Assertions.checkArgument(!C.COMMON_PSSH_UUID.equals(uuid), "Use C.CLEARKEY_UUID instead"); this.uuid = uuid; @@ -359,9 +396,12 @@ public class DefaultDrmSessionManager implements DrmSessionManager { this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; provisioningManagerImpl = new ProvisioningManagerImpl(); + referenceCountListener = new ReferenceCountListenerImpl(); mode = MODE_PLAYBACK; sessions = new ArrayList<>(); provisioningSessions = new ArrayList<>(); + keepaliveSessions = Sets.newIdentityHashSet(); + this.sessionKeepaliveMs = sessionKeepaliveMs; } /** @@ -411,6 +451,13 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public final void release() { if (--prepareCallsCount == 0) { + // Make a local copy, because sessions are removed from this.sessions during release (via + // callback). + List sessions = new ArrayList<>(this.sessions); + for (int i = 0; i < sessions.size(); i++) { + // Release all the keepalive acquisitions. + sessions.get(i).release(/* eventDispatcher= */ null); + } Assertions.checkNotNull(exoMediaDrm).release(); exoMediaDrm = null; } @@ -451,7 +498,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override @Nullable public DrmSession acquirePlaceholderSession(Looper playbackLooper, int trackType) { - assertExpectedPlaybackLooper(playbackLooper); + initPlaybackLooper(playbackLooper); ExoMediaDrm exoMediaDrm = Assertions.checkNotNull(this.exoMediaDrm); boolean avoidPlaceholderDrmSessions = FrameworkMediaCrypto.class.equals(exoMediaDrm.getExoMediaCryptoType()) @@ -465,12 +512,15 @@ public class DefaultDrmSessionManager implements DrmSessionManager { maybeCreateMediaDrmHandler(playbackLooper); if (placeholderDrmSession == null) { DefaultDrmSession placeholderDrmSession = - createNewDefaultSession( - /* schemeDatas= */ Collections.emptyList(), /* isPlaceholderSession= */ true); + createAndAcquireSessionWithRetry( + /* schemeDatas= */ ImmutableList.of(), + /* isPlaceholderSession= */ true, + /* eventDispatcher= */ null); sessions.add(placeholderDrmSession); this.placeholderDrmSession = placeholderDrmSession; + } else { + placeholderDrmSession.acquire(/* eventDispatcher= */ null); } - placeholderDrmSession.acquire(/* eventDispatcher= */ null); return placeholderDrmSession; } @@ -479,7 +529,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { Looper playbackLooper, @Nullable MediaSourceEventDispatcher eventDispatcher, DrmInitData drmInitData) { - assertExpectedPlaybackLooper(playbackLooper); + initPlaybackLooper(playbackLooper); maybeCreateMediaDrmHandler(playbackLooper); @Nullable List schemeDatas = null; @@ -513,13 +563,17 @@ public class DefaultDrmSessionManager implements DrmSessionManager { if (session == null) { // Create a new session. - session = createNewDefaultSession(schemeDatas, /* isPlaceholderSession= */ false); + session = + createAndAcquireSessionWithRetry( + schemeDatas, /* isPlaceholderSession= */ false, eventDispatcher); if (!multiSession) { noMultiSessionDrmSession = session; } sessions.add(session); + } else { + session.acquire(eventDispatcher); } - session.acquire(eventDispatcher); + return session; } @@ -533,9 +587,13 @@ public class DefaultDrmSessionManager implements DrmSessionManager { // Internal methods. - private void assertExpectedPlaybackLooper(Looper playbackLooper) { - Assertions.checkState(this.playbackLooper == null || this.playbackLooper == playbackLooper); - this.playbackLooper = playbackLooper; + private void initPlaybackLooper(Looper playbackLooper) { + if (this.playbackLooper == null) { + this.playbackLooper = playbackLooper; + this.sessionReleasingHandler = new Handler(playbackLooper); + } else { + Assertions.checkState(this.playbackLooper == playbackLooper); + } } private void maybeCreateMediaDrmHandler(Looper playbackLooper) { @@ -544,41 +602,77 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } - private DefaultDrmSession createNewDefaultSession( - @Nullable List schemeDatas, boolean isPlaceholderSession) { + private DefaultDrmSession createAndAcquireSessionWithRetry( + @Nullable List schemeDatas, + boolean isPlaceholderSession, + @Nullable MediaSourceEventDispatcher eventDispatcher) { + DefaultDrmSession session = + createAndAcquireSession(schemeDatas, isPlaceholderSession, eventDispatcher); + if (session.getState() == DrmSession.STATE_ERROR + && (Util.SDK_INT < 19 + || Assertions.checkNotNull(session.getError()).getCause() + instanceof ResourceBusyException)) { + // We're short on DRM session resources, so eagerly release all our keepalive sessions. + // ResourceBusyException is only available at API 19, so on earlier versions we always + // eagerly release regardless of the underlying error. + if (!keepaliveSessions.isEmpty()) { + // Make a local copy, because sessions are removed from this.timingOutSessions during + // release (via callback). + ImmutableList timingOutSessions = + ImmutableList.copyOf(this.keepaliveSessions); + for (DrmSession timingOutSession : timingOutSessions) { + timingOutSession.release(/* eventDispatcher= */ null); + } + // Undo the acquisitions from createAndAcquireSession(). + session.release(eventDispatcher); + if (sessionKeepaliveMs != C.TIME_UNSET) { + session.release(/* eventDispatcher= */ null); + } + session = createAndAcquireSession(schemeDatas, isPlaceholderSession, eventDispatcher); + } + } + return session; + } + + /** + * Creates a new {@link DefaultDrmSession} and acquires it on behalf of the caller (passing in + * {@code eventDispatcher}). + * + *

        If {@link #sessionKeepaliveMs} != {@link C#TIME_UNSET} then acquires it again to allow the + * manager to keep it alive (passing in {@code eventDispatcher=null}. + */ + private DefaultDrmSession createAndAcquireSession( + @Nullable List schemeDatas, + boolean isPlaceholderSession, + @Nullable MediaSourceEventDispatcher eventDispatcher) { Assertions.checkNotNull(exoMediaDrm); // Placeholder sessions should always play clear samples without keys. boolean playClearSamplesWithoutKeys = this.playClearSamplesWithoutKeys | isPlaceholderSession; - return new DefaultDrmSession( - uuid, - exoMediaDrm, - /* provisioningManager= */ provisioningManagerImpl, - /* releaseCallback= */ this::onSessionReleased, - schemeDatas, - mode, - playClearSamplesWithoutKeys, - isPlaceholderSession, - offlineLicenseKeySetId, - keyRequestParameters, - callback, - Assertions.checkNotNull(playbackLooper), - loadErrorHandlingPolicy); - } - - private void onSessionReleased(DefaultDrmSession drmSession) { - sessions.remove(drmSession); - if (placeholderDrmSession == drmSession) { - placeholderDrmSession = null; + DefaultDrmSession session = + new DefaultDrmSession( + uuid, + exoMediaDrm, + /* provisioningManager= */ provisioningManagerImpl, + referenceCountListener, + schemeDatas, + mode, + playClearSamplesWithoutKeys, + isPlaceholderSession, + offlineLicenseKeySetId, + keyRequestParameters, + callback, + Assertions.checkNotNull(playbackLooper), + loadErrorHandlingPolicy); + // Acquire the session once on behalf of the caller to DrmSessionManager - this is the + // reference 'assigned' to the caller which they're responsible for releasing. Do this first, + // to ensure that eventDispatcher receives all events related to the initial + // acquisition/opening. + session.acquire(eventDispatcher); + if (sessionKeepaliveMs != C.TIME_UNSET) { + // Acquire the session once more so the Manager can keep it alive. + session.acquire(/* eventDispatcher= */ null); } - if (noMultiSessionDrmSession == drmSession) { - noMultiSessionDrmSession = null; - } - if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == drmSession) { - // Other sessions were waiting for the released session to complete a provision operation. - // We need to have one of those sessions perform the provision operation instead. - provisioningSessions.get(1).provision(); - } - provisioningSessions.remove(drmSession); + return session; } /** @@ -661,6 +755,52 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } + private class ReferenceCountListenerImpl implements DefaultDrmSession.ReferenceCountListener { + + @Override + public void onReferenceCountIncremented(DefaultDrmSession session, int newReferenceCount) { + if (sessionKeepaliveMs != C.TIME_UNSET) { + // The session has been acquired elsewhere so we want to cancel our timeout. + keepaliveSessions.remove(session); + Assertions.checkNotNull(sessionReleasingHandler).removeCallbacksAndMessages(session); + } + } + + @Override + public void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount) { + if (newReferenceCount == 1 && sessionKeepaliveMs != C.TIME_UNSET) { + // Only the internal keep-alive reference remains, so we can start the timeout. + keepaliveSessions.add(session); + Assertions.checkNotNull(sessionReleasingHandler) + .postAtTime( + () -> { + session.release(/* eventDispatcher= */ null); + }, + session, + /* uptimeMillis= */ SystemClock.uptimeMillis() + sessionKeepaliveMs); + } else if (newReferenceCount == 0) { + // This session is fully released. + sessions.remove(session); + if (placeholderDrmSession == session) { + placeholderDrmSession = null; + } + if (noMultiSessionDrmSession == session) { + noMultiSessionDrmSession = null; + } + if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == session) { + // Other sessions were waiting for the released session to complete a provision operation. + // We need to have one of those sessions perform the provision operation instead. + provisioningSessions.get(1).provision(); + } + provisioningSessions.remove(session); + if (sessionKeepaliveMs != C.TIME_UNSET) { + Assertions.checkNotNull(sessionReleasingHandler).removeCallbacksAndMessages(session); + keepaliveSessions.remove(session); + } + } + } + } + private class MediaDrmEventListener implements OnEventListener { @Override diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java index 73f68d1202..3c203f1457 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java @@ -16,9 +16,11 @@ package com.google.android.exoplayer2.drm; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.Assertions; @@ -47,7 +49,7 @@ public class DefaultDrmSessionManagerTest { private static final DrmInitData DRM_INIT_DATA = new DrmInitData(DRM_SCHEME_DATAS); @Test(timeout = 10_000) - public void acquireSessionTriggersKeyLoadAndSessionIsOpened() throws Exception { + public void acquireSession_triggersKeyLoadAndSessionIsOpened() throws Exception { FakeExoMediaDrm.LicenseServer licenseServer = FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); @@ -68,6 +70,151 @@ public class DefaultDrmSessionManagerTest { .containsExactly(FakeExoMediaDrm.KEY_STATUS_KEY, FakeExoMediaDrm.KEY_STATUS_AVAILABLE); } + @Test(timeout = 10_000) + public void keepaliveEnabled_sessionsKeptForRequestedTime() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + drmSession.release(/* eventDispatcher= */ null); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + ShadowLooper.idleMainLooper(10, SECONDS); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void keepaliveDisabled_sessionsReleasedImmediately() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(C.TIME_UNSET) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + drmSession.release(/* eventDispatcher= */ null); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void managerRelease_allKeepaliveSessionsImmediatelyReleased() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + drmSession.release(/* eventDispatcher= */ null); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + drmSessionManager.release(); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void maxConcurrentSessionsExceeded_allKeepAliveSessionsEagerlyReleased() throws Exception { + ImmutableList secondSchemeDatas = + ImmutableList.of(DRM_SCHEME_DATAS.get(0).copyWithData(TestUtil.createByteArray(4, 5, 6))); + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS, secondSchemeDatas); + DrmInitData secondInitData = new DrmInitData(secondSchemeDatas); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm(/* maxConcurrentSessions= */ 1)) + .setSessionKeepaliveMs(10_000) + .setMultiSession(true) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession firstDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(firstDrmSession); + firstDrmSession.release(/* eventDispatcher= */ null); + + // All external references to firstDrmSession have been released, it's being kept alive by + // drmSessionManager's internal reference. + assertThat(firstDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + DrmSession secondDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + secondInitData); + // The drmSessionManager had to release firstDrmSession in order to acquire secondDrmSession. + assertThat(firstDrmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + + waitForOpenedWithKeys(secondDrmSession); + assertThat(secondDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + } + + @Test(timeout = 10_000) + public void sessionReacquired_keepaliveTimeOutCancelled() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession firstDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(firstDrmSession); + firstDrmSession.release(/* eventDispatcher= */ null); + + ShadowLooper.idleMainLooper(5, SECONDS); + + // Acquire a session for the same init data 5s in to the 10s timeout (so expect the same + // instance). + DrmSession secondDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + assertThat(secondDrmSession).isSameInstanceAs(firstDrmSession); + + // Let the timeout definitely expire, and check the session didn't get released. + ShadowLooper.idleMainLooper(10, SECONDS); + assertThat(secondDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + } + private static void waitForOpenedWithKeys(DrmSession drmSession) { // Check the error first, so we get a meaningful failure if there's been an error. assertThat(drmSession.getError()).isNull(); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java index cf422ffab3..c698b2e8b3 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java @@ -20,6 +20,7 @@ import android.media.DeniedByServerException; import android.media.MediaCryptoException; import android.media.MediaDrmException; import android.media.NotProvisionedException; +import android.media.ResourceBusyException; import android.os.Parcel; import android.os.Parcelable; import android.os.PersistableBundle; @@ -57,7 +58,7 @@ import java.util.concurrent.atomic.AtomicInteger; // TODO: Consider replacing this with a Robolectric ShadowMediaDrm so we can use a real // FrameworkMediaDrm. @RequiresApi(29) -public class FakeExoMediaDrm implements ExoMediaDrm { +public final class FakeExoMediaDrm implements ExoMediaDrm { public static final ProvisionRequest DUMMY_PROVISION_REQUEST = new ProvisionRequest(TestUtil.createByteArray(7, 8, 9), "bar.test"); @@ -72,6 +73,7 @@ public class FakeExoMediaDrm implements ExoMediaDrm { private static final ImmutableList VALID_KEY_RESPONSE = TestUtil.createByteList(1, 2, 3); private static final ImmutableList KEY_DENIED_RESPONSE = TestUtil.createByteList(9, 8, 7); + private final int maxConcurrentSessions; private final Map byteProperties; private final Map stringProperties; private final Set> openSessionIds; @@ -82,9 +84,20 @@ public class FakeExoMediaDrm implements ExoMediaDrm { /** * Constructs an instance that returns random and unique {@code sessionIds} for subsequent calls - * to {@link #openSession()}. + * to {@link #openSession()} with no limit on the number of concurrent open sessions. */ public FakeExoMediaDrm() { + this(/* maxConcurrentSessions= */ Integer.MAX_VALUE); + } + + /** + * Constructs an instance that returns random and unique {@code sessionIds} for subsequent calls + * to {@link #openSession()} with a limit on the number of concurrent open sessions. + * + * @param maxConcurrentSessions The max number of sessions allowed to be open simultaneously. + */ + public FakeExoMediaDrm(int maxConcurrentSessions) { + this.maxConcurrentSessions = maxConcurrentSessions; byteProperties = new HashMap<>(); stringProperties = new HashMap<>(); openSessionIds = new HashSet<>(); @@ -114,6 +127,9 @@ public class FakeExoMediaDrm implements ExoMediaDrm { @Override public byte[] openSession() throws MediaDrmException { Assertions.checkState(referenceCount > 0); + if (openSessionIds.size() >= maxConcurrentSessions) { + throw new ResourceBusyException("Too many sessions open. max=" + maxConcurrentSessions); + } byte[] sessionId = TestUtil.buildTestData(/* length= */ 10, sessionIdGenerator.incrementAndGet()); if (!openSessionIds.add(toByteList(sessionId))) { From f8217140d7fc081f062bb8ef6613a11c32c867cb Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 1 Jul 2020 10:25:27 +0100 Subject: [PATCH 0578/1052] Add missing null check. The system services may return a null value if the service is not available. Guard against this by falling back to default values. PiperOrigin-RevId: 319187882 --- .../google/android/exoplayer2/ui/SubtitleView.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index e066fa0f8a..72a3a8384e 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -327,23 +327,28 @@ public final class SubtitleView extends FrameLayout implements TextOutput { @RequiresApi(19) private boolean isCaptionManagerEnabled() { + @Nullable CaptioningManager captioningManager = (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE); - return captioningManager.isEnabled(); + return captioningManager != null && captioningManager.isEnabled(); } @RequiresApi(19) private float getUserCaptionFontScaleV19() { + @Nullable CaptioningManager captioningManager = (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE); - return captioningManager.getFontScale(); + return captioningManager == null ? 1f : captioningManager.getFontScale(); } @RequiresApi(19) private CaptionStyleCompat getUserCaptionStyleV19() { + @Nullable CaptioningManager captioningManager = (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE); - return CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); + return captioningManager == null + ? CaptionStyleCompat.DEFAULT + : CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); } private void updateOutput() { From 8bd01a7bec1ffdfa6eb62bbf7d5ed103c7886691 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 1 Jul 2020 13:12:54 +0100 Subject: [PATCH 0579/1052] Discard samples on the write-side of SampleQueue PiperOrigin-RevId: 319205008 --- .../source/ProgressiveMediaPeriod.java | 6 +- .../exoplayer2/source/SampleQueue.java | 73 +++-- .../source/chunk/ChunkSampleStream.java | 28 +- .../exoplayer2/source/SampleQueueTest.java | 287 ++++++++++++------ .../source/dash/PlayerEmsgHandler.java | 6 +- .../source/hls/HlsSampleStreamWrapper.java | 6 +- 6 files changed, 264 insertions(+), 142 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 3bfb8356a5..546d56bccf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -480,8 +480,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } maybeNotifyDownstreamFormat(sampleQueueIndex); int result = - sampleQueues[sampleQueueIndex].read( - formatHolder, buffer, formatRequired, loadingFinished, lastSeekPositionUs); + sampleQueues[sampleQueueIndex].read(formatHolder, buffer, formatRequired, loadingFinished); if (result == C.RESULT_NOTHING_READ) { maybeStartDeferredRetry(sampleQueueIndex); } @@ -815,6 +814,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; loadable.setLoadPosition( Assertions.checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position, pendingResetPositionUs); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setStartTimeUs(pendingResetPositionUs); + } pendingResetPositionUs = C.TIME_UNSET; } extractedSamplesCountAtStartOfLoad = getExtractedSamplesCount(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index f2c9a11246..b65d86279a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source; import android.os.Looper; +import android.util.Log; import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -52,6 +53,7 @@ public class SampleQueue implements TrackOutput { } @VisibleForTesting /* package */ static final int SAMPLE_CAPACITY_INCREMENT = 1000; + private static final String TAG = "SampleQueue"; private final SampleDataQueue sampleDataQueue; private final SampleExtrasHolder extrasHolder; @@ -77,6 +79,7 @@ public class SampleQueue implements TrackOutput { private int relativeFirstIndex; private int readPosition; + private long startTimeUs; private long largestDiscardedTimestampUs; private long largestQueuedTimestampUs; private boolean isLastSampleQueued; @@ -87,6 +90,8 @@ public class SampleQueue implements TrackOutput { @Nullable private Format upstreamFormat; @Nullable private Format upstreamCommittedFormat; private int upstreamSourceId; + private boolean upstreamAllSamplesAreSyncSamples; + private boolean loggedUnexpectedNonSyncSample; private long sampleOffsetUs; private boolean pendingSplice; @@ -119,6 +124,7 @@ public class SampleQueue implements TrackOutput { sizes = new int[capacity]; cryptoDatas = new CryptoData[capacity]; formats = new Format[capacity]; + startTimeUs = Long.MIN_VALUE; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; upstreamFormatRequired = true; @@ -155,6 +161,7 @@ public class SampleQueue implements TrackOutput { relativeFirstIndex = 0; readPosition = 0; upstreamKeyframeRequired = true; + startTimeUs = Long.MIN_VALUE; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; isLastSampleQueued = false; @@ -166,6 +173,16 @@ public class SampleQueue implements TrackOutput { } } + /** + * Sets the start time for the queue. Samples with earlier timestamps will be discarded or have + * the {@link C#BUFFER_FLAG_DECODE_ONLY} flag set when read. + * + * @param startTimeUs The start time, in microseconds. + */ + public final void setStartTimeUs(long startTimeUs) { + this.startTimeUs = startTimeUs; + } + /** * Sets a source identifier for subsequent samples. * @@ -325,8 +342,6 @@ public class SampleQueue implements TrackOutput { * it's not changing. A sample will never be read if set to true, however it is still possible * for the end of stream or nothing to be read. * @param loadingFinished True if an empty queue should be considered the end of the stream. - * @param decodeOnlyUntilUs If a buffer is read, the {@link C#BUFFER_FLAG_DECODE_ONLY} flag will - * be set if the buffer's timestamp is less than this value. * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or * {@link C#RESULT_BUFFER_READ}. */ @@ -335,11 +350,9 @@ public class SampleQueue implements TrackOutput { FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired, - boolean loadingFinished, - long decodeOnlyUntilUs) { + boolean loadingFinished) { int result = - readSampleMetadata( - formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilUs, extrasHolder); + readSampleMetadata(formatHolder, buffer, formatRequired, loadingFinished, extrasHolder); if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream() && !buffer.isFlagsOnly()) { sampleDataQueue.readToBuffer(buffer, extrasHolder); } @@ -357,6 +370,7 @@ public class SampleQueue implements TrackOutput { if (sampleIndex < absoluteFirstIndex || sampleIndex > absoluteFirstIndex + length) { return false; } + startTimeUs = Long.MIN_VALUE; readPosition = sampleIndex - absoluteFirstIndex; return true; } @@ -382,6 +396,7 @@ public class SampleQueue implements TrackOutput { if (offset == -1) { return false; } + startTimeUs = timeUs; readPosition += offset; return true; } @@ -513,6 +528,22 @@ public class SampleQueue implements TrackOutput { } timeUs += sampleOffsetUs; + if (upstreamAllSamplesAreSyncSamples) { + if (timeUs < startTimeUs) { + // If we know that all samples are sync samples, we can discard those that come before the + // start time on the write side of the queue. + return; + } + if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0) { + // The flag should always be set unless the source content has incorrect sample metadata. + // Log a warning (once per format change, to avoid log spam) and override the flag. + if (!loggedUnexpectedNonSyncSample) { + Log.w(TAG, "Overriding unexpected non-sync sample for format: " + upstreamFormat); + loggedUnexpectedNonSyncSample = true; + } + flags |= C.BUFFER_FLAG_KEY_FRAME; + } + } if (pendingSplice) { if (!isKeyframe || !attemptSplice(timeUs)) { return; @@ -568,25 +599,9 @@ public class SampleQueue implements TrackOutput { DecoderInputBuffer buffer, boolean formatRequired, boolean loadingFinished, - long decodeOnlyUntilUs, SampleExtrasHolder extrasHolder) { buffer.waitingForKeys = false; - // This is a temporary fix for https://github.com/google/ExoPlayer/issues/6155. - // TODO: Remove it and replace it with a fix that discards samples when writing to the queue. - boolean hasNextSample; - int relativeReadIndex = C.INDEX_UNSET; - while ((hasNextSample = hasNextSample())) { - relativeReadIndex = getRelativeIndex(readPosition); - long timeUs = timesUs[relativeReadIndex]; - if (timeUs < decodeOnlyUntilUs - && MimeTypes.allSamplesAreSyncSamples(formats[relativeReadIndex].sampleMimeType)) { - readPosition++; - } else { - break; - } - } - - if (!hasNextSample) { + if (!hasNextSample()) { if (loadingFinished || isLastSampleQueued) { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; @@ -598,6 +613,7 @@ public class SampleQueue implements TrackOutput { } } + int relativeReadIndex = getRelativeIndex(readPosition); if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { onFormatResult(formats[relativeReadIndex], formatHolder); return C.RESULT_FORMAT_READ; @@ -610,7 +626,7 @@ public class SampleQueue implements TrackOutput { buffer.setFlags(flags[relativeReadIndex]); buffer.timeUs = timesUs[relativeReadIndex]; - if (buffer.timeUs < decodeOnlyUntilUs) { + if (buffer.timeUs < startTimeUs) { buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); } if (buffer.isFlagsOnly()) { @@ -631,16 +647,19 @@ public class SampleQueue implements TrackOutput { // current upstreamFormat so we can detect format changes on the read side using cheap // referential quality. return false; - } else if (Util.areEqual(format, upstreamCommittedFormat)) { + } + if (Util.areEqual(format, upstreamCommittedFormat)) { // The format has changed back to the format of the last committed sample. If they are // different objects, we revert back to using upstreamCommittedFormat as the upstreamFormat // so we can detect format changes on the read side using cheap referential equality. upstreamFormat = upstreamCommittedFormat; - return true; } else { upstreamFormat = format; - return true; } + upstreamAllSamplesAreSyncSamples = + MimeTypes.allSamplesAreSyncSamples(upstreamFormat.sampleMimeType); + loggedUnexpectedNonSyncSample = false; + return true; } private synchronized long discardSampleMetadataTo( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index 6efe25420c..0e193a1a2a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -87,7 +87,6 @@ public class ChunkSampleStream implements SampleStream, S private long lastSeekPositionUs; private int nextNotifyPrimaryFormatMediaChunkIndex; - /* package */ long decodeOnlyUntilPositionUs; /* package */ boolean loadingFinished; /** @@ -282,14 +281,12 @@ public class ChunkSampleStream implements SampleStream, S if (seekToMediaChunk != null) { // When seeking to the start of a chunk we use the index of the first sample in the chunk // rather than the seek position. This ensures we seek to the keyframe at the start of the - // chunk even if the sample timestamps are slightly offset from the chunk start times. + // chunk even if its timestamp is slightly earlier than the advertised chunk start time. seekInsideBuffer = primarySampleQueue.seekTo(seekToMediaChunk.getFirstSampleIndex(0)); - decodeOnlyUntilPositionUs = 0; } else { seekInsideBuffer = primarySampleQueue.seekTo( positionUs, /* allowTimeBeyondBuffer= */ positionUs < getNextLoadPositionUs()); - decodeOnlyUntilPositionUs = lastSeekPositionUs; } if (seekInsideBuffer) { @@ -383,8 +380,7 @@ public class ChunkSampleStream implements SampleStream, S } maybeNotifyPrimaryTrackFormatChanged(); - return primarySampleQueue.read( - formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilPositionUs); + return primarySampleQueue.read(formatHolder, buffer, formatRequired, loadingFinished); } @Override @@ -577,9 +573,16 @@ public class ChunkSampleStream implements SampleStream, S if (isMediaChunk(loadable)) { BaseMediaChunk mediaChunk = (BaseMediaChunk) loadable; if (pendingReset) { - boolean resetToMediaChunk = mediaChunk.startTimeUs == pendingResetPositionUs; - // Only enable setting of the decode only flag if we're not resetting to a chunk boundary. - decodeOnlyUntilPositionUs = resetToMediaChunk ? 0 : pendingResetPositionUs; + // Only set the queue start times if we're not seeking to a chunk boundary. If we are + // seeking to a chunk boundary then we want the queue to pass through all of the samples in + // the chunk. Doing this ensures we'll always output the keyframe at the start of the chunk, + // even if its timestamp is slightly earlier than the advertised chunk start time. + if (mediaChunk.startTimeUs != pendingResetPositionUs) { + primarySampleQueue.setStartTimeUs(pendingResetPositionUs); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.setStartTimeUs(pendingResetPositionUs); + } + } pendingResetPositionUs = C.TIME_UNSET; } mediaChunk.init(chunkOutput); @@ -799,12 +802,7 @@ public class ChunkSampleStream implements SampleStream, S return C.RESULT_NOTHING_READ; } maybeNotifyDownstreamFormat(); - return sampleQueue.read( - formatHolder, - buffer, - formatRequired, - loadingFinished, - decodeOnlyUntilPositionUs); + return sampleQueue.read(formatHolder, buffer, formatRequired, loadingFinished); } public void release() { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 480735a689..bc8ed07167 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -42,6 +42,7 @@ import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.common.primitives.Bytes; import java.io.IOException; @@ -180,6 +181,7 @@ public final class SampleQueueTest { assertReadSample( /* timeUs= */ i * 1000, /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, /* sampleData= */ new byte[1], /* offset= */ 0, @@ -226,9 +228,23 @@ public final class SampleQueueTest { sampleQueue.sampleMetadata(1000, C.BUFFER_FLAG_KEY_FRAME, ALLOCATION_SIZE, 0, null); assertReadFormat(false, FORMAT_1); - assertReadSample(0, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 0, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); // Assert the second sample is read without a format change. - assertReadSample(1000, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 1000, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); // The same applies if the queue is empty when the formats are written. sampleQueue.format(FORMAT_2); @@ -237,7 +253,14 @@ public final class SampleQueueTest { sampleQueue.sampleMetadata(2000, C.BUFFER_FLAG_KEY_FRAME, ALLOCATION_SIZE, 0, null); // Assert the third sample is read without a format change. - assertReadSample(2000, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 2000, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); } @Test @@ -260,7 +283,14 @@ public final class SampleQueueTest { // If formatRequired, should read the format rather than the sample. assertReadFormat(true, FORMAT_1); // Otherwise should read the sample. - assertReadSample(1000, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 1000, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); // Allocation should still be held. assertAllocationCount(1); sampleQueue.discardToRead(); @@ -277,7 +307,14 @@ public final class SampleQueueTest { // If formatRequired, should read the format rather than the sample. assertReadFormat(true, FORMAT_1); // Read the sample. - assertReadSample(2000, false, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE - 1); + assertReadSample( + 2000, + /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE - 1); // Allocation should still be held. assertAllocationCount(1); sampleQueue.discardToRead(); @@ -291,7 +328,14 @@ public final class SampleQueueTest { // If formatRequired, should read the format rather than the sample. assertReadFormat(true, FORMAT_1); // Read the sample. - assertReadSample(3000, false, /* isEncrypted= */ false, DATA, ALLOCATION_SIZE - 1, 1); + assertReadSample( + 3000, + /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + ALLOCATION_SIZE - 1, + 1); // Allocation should still be held. assertAllocationCount(1); sampleQueue.discardToRead(); @@ -394,11 +438,7 @@ public final class SampleQueueTest { int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 0); @@ -407,21 +447,13 @@ public final class SampleQueueTest { assertThat(formatHolder.drmSession).isNull(); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isNull(); assertReadEncryptedSample(/* sampleIndex= */ 2); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); } @@ -438,11 +470,7 @@ public final class SampleQueueTest { int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 0); @@ -451,21 +479,13 @@ public final class SampleQueueTest { assertThat(formatHolder.drmSession).isNull(); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockPlaceholderDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 2); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 3); @@ -495,11 +515,7 @@ public final class SampleQueueTest { int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); // Fill cryptoInfo.iv with non-zero data. When the 8 byte initialization vector is written into @@ -509,11 +525,7 @@ public final class SampleQueueTest { result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_BUFFER_READ); // Assert cryptoInfo.iv contains the 8-byte initialization vector and that the trailing 8 bytes @@ -608,7 +620,14 @@ public final class SampleQueueTest { sampleQueue.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, ALLOCATION_SIZE, 0, null); // Once the metadata has been written, check the sample can be read as expected. - assertReadSample(0, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + /* timeUs= */ 0, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); assertNoSamplesToRead(FORMAT_1); assertAllocationCount(1); sampleQueue.discardToRead(); @@ -641,7 +660,7 @@ public final class SampleQueueTest { int skipCount = sampleQueue.advanceTo(LAST_SAMPLE_TIMESTAMP); // Should advance to 2nd keyframe (the 4th frame). assertThat(skipCount).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData(/* startFormat= */ null, DATA_SECOND_KEYFRAME_INDEX); assertNoSamplesToRead(FORMAT_2); } @@ -651,7 +670,7 @@ public final class SampleQueueTest { int skipCount = sampleQueue.advanceTo(LAST_SAMPLE_TIMESTAMP + 1); // Should advance to 2nd keyframe (the 4th frame). assertThat(skipCount).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData(/* startFormat= */ null, DATA_SECOND_KEYFRAME_INDEX); assertNoSamplesToRead(FORMAT_2); } @@ -681,7 +700,12 @@ public final class SampleQueueTest { boolean success = sampleQueue.seekTo(LAST_SAMPLE_TIMESTAMP, false); assertThat(success).isTrue(); assertThat(sampleQueue.getReadIndex()).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData( + /* startFormat= */ null, + DATA_SECOND_KEYFRAME_INDEX, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length - DATA_SECOND_KEYFRAME_INDEX, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP); assertNoSamplesToRead(FORMAT_2); } @@ -701,7 +725,12 @@ public final class SampleQueueTest { boolean success = sampleQueue.seekTo(LAST_SAMPLE_TIMESTAMP + 1, true); assertThat(success).isTrue(); assertThat(sampleQueue.getReadIndex()).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData( + /* startFormat= */ null, + DATA_SECOND_KEYFRAME_INDEX, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length - DATA_SECOND_KEYFRAME_INDEX, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP + 1); assertNoSamplesToRead(FORMAT_2); } @@ -711,7 +740,13 @@ public final class SampleQueueTest { boolean success = sampleQueue.seekTo(LAST_SAMPLE_TIMESTAMP, false); assertThat(success).isTrue(); assertThat(sampleQueue.getReadIndex()).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData( + /* startFormat= */ null, + DATA_SECOND_KEYFRAME_INDEX, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length - DATA_SECOND_KEYFRAME_INDEX, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP); + assertNoSamplesToRead(FORMAT_2); // Seek back to the start. success = sampleQueue.seekTo(SAMPLE_TIMESTAMPS[0], false); @@ -721,6 +756,51 @@ public final class SampleQueueTest { assertNoSamplesToRead(FORMAT_2); } + @Test + public void setStartTimeUs_allSamplesAreSyncSamples_discardsOnWriteSide() { + // The format uses a MIME type for which MimeTypes.allSamplesAreSyncSamples() is true. + Format format = new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_RAW).build(); + Format[] sampleFormats = new Format[SAMPLE_SIZES.length]; + Arrays.fill(sampleFormats, format); + int[] sampleFlags = new int[SAMPLE_SIZES.length]; + Arrays.fill(sampleFlags, BUFFER_FLAG_KEY_FRAME); + + sampleQueue.setStartTimeUs(LAST_SAMPLE_TIMESTAMP); + writeTestData( + DATA, SAMPLE_SIZES, SAMPLE_OFFSETS, SAMPLE_TIMESTAMPS, sampleFormats, sampleFlags); + + assertThat(sampleQueue.getReadIndex()).isEqualTo(0); + + assertReadFormat(/* formatRequired= */ false, format); + assertReadSample( + SAMPLE_TIMESTAMPS[7], + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + DATA.length - SAMPLE_OFFSETS[7] - SAMPLE_SIZES[7], + SAMPLE_SIZES[7]); + } + + @Test + public void setStartTimeUs_notAllSamplesAreSyncSamples_discardsOnReadSide() { + // The format uses a MIME type for which MimeTypes.allSamplesAreSyncSamples() is false. + Format format = new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build(); + Format[] sampleFormats = new Format[SAMPLE_SIZES.length]; + Arrays.fill(sampleFormats, format); + + sampleQueue.setStartTimeUs(LAST_SAMPLE_TIMESTAMP); + writeTestData(); + + assertThat(sampleQueue.getReadIndex()).isEqualTo(0); + assertReadTestData( + /* startFormat= */ null, + /* firstSampleIndex= */ 0, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP); + } + @Test public void discardToEnd() { writeTestData(); @@ -745,7 +825,7 @@ public final class SampleQueueTest { assertThat(sampleQueue.getReadIndex()).isEqualTo(0); assertAllocationCount(10); // Read the first sample. - assertReadTestData(null, 0, 1); + assertReadTestData(/* startFormat= */ null, 0, 1); // Shouldn't discard anything. sampleQueue.discardTo(SAMPLE_TIMESTAMPS[1] - 1, false, true); assertThat(sampleQueue.getFirstIndex()).isEqualTo(0); @@ -835,7 +915,7 @@ public final class SampleQueueTest { writeTestData(); sampleQueue.discardUpstreamSamples(4); assertAllocationCount(4); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); assertReadFormat(false, FORMAT_2); assertNoSamplesToRead(FORMAT_2); } @@ -843,7 +923,7 @@ public final class SampleQueueTest { @Test public void discardUpstreamAfterRead() { writeTestData(); - assertReadTestData(null, 0, 3); + assertReadTestData(/* startFormat= */ null, 0, 3); sampleQueue.discardUpstreamSamples(8); assertAllocationCount(10); sampleQueue.discardToRead(); @@ -908,7 +988,11 @@ public final class SampleQueueTest { sampleQueue.setSampleOffsetUs(sampleOffsetUs); writeTestData(); assertReadTestData( - /* startFormat= */ null, /* firstSampleIndex= */ 0, /* sampleCount= */ 8, sampleOffsetUs); + /* startFormat= */ null, + /* firstSampleIndex= */ 0, + /* sampleCount= */ 8, + sampleOffsetUs, + /* decodeOnlyUntilUs= */ 0); assertReadEndOfStream(/* formatRequired= */ false); } @@ -931,6 +1015,7 @@ public final class SampleQueueTest { assertReadSample( unadjustedTimestampUs + sampleOffsetUs, /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -986,6 +1071,7 @@ public final class SampleQueueTest { assertReadSample( /* timeUs= */ 0, /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -994,6 +1080,7 @@ public final class SampleQueueTest { assertReadSample( /* timeUs= */ 1, /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -1009,16 +1096,23 @@ public final class SampleQueueTest { long spliceSampleTimeUs = SAMPLE_TIMESTAMPS[4]; writeFormat(FORMAT_SPLICED); writeSample(DATA, spliceSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); assertReadFormat(false, FORMAT_SPLICED); - assertReadSample(spliceSampleTimeUs, true, /* isEncrypted= */ false, DATA, 0, DATA.length); + assertReadSample( + spliceSampleTimeUs, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + DATA.length); assertReadEndOfStream(false); } @Test public void spliceAfterRead() { writeTestData(); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); sampleQueue.splice(); // Splice should fail, leaving the last 4 samples unchanged. long spliceSampleTimeUs = SAMPLE_TIMESTAMPS[3]; @@ -1028,14 +1122,21 @@ public final class SampleQueueTest { assertReadEndOfStream(false); sampleQueue.seekTo(0); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); sampleQueue.splice(); // Splice should succeed, replacing the last 4 samples with the sample being written spliceSampleTimeUs = SAMPLE_TIMESTAMPS[3] + 1; writeFormat(FORMAT_SPLICED); writeSample(DATA, spliceSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME); assertReadFormat(false, FORMAT_SPLICED); - assertReadSample(spliceSampleTimeUs, true, /* isEncrypted= */ false, DATA, 0, DATA.length); + assertReadSample( + spliceSampleTimeUs, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + DATA.length); assertReadEndOfStream(false); } @@ -1049,14 +1150,23 @@ public final class SampleQueueTest { long spliceSampleTimeUs = SAMPLE_TIMESTAMPS[4]; writeFormat(FORMAT_SPLICED); writeSample(DATA, spliceSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME); - assertReadTestData(null, 0, 4, sampleOffsetUs); + assertReadTestData(/* startFormat= */ null, 0, 4, sampleOffsetUs, /* decodeOnlyUntilUs= */ 0); assertReadFormat( false, FORMAT_SPLICED.buildUpon().setSubsampleOffsetUs(sampleOffsetUs).build()); assertReadSample( - spliceSampleTimeUs + sampleOffsetUs, true, /* isEncrypted= */ false, DATA, 0, DATA.length); + spliceSampleTimeUs + sampleOffsetUs, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + DATA.length); assertReadEndOfStream(false); } + @Test + public void setStartTime() {} + // Internal methods. /** @@ -1119,7 +1229,7 @@ public final class SampleQueueTest { * Asserts correct reading of standard test data from {@code sampleQueue}. */ private void assertReadTestData() { - assertReadTestData(null, 0); + assertReadTestData(/* startFormat= */ null, 0); } /** @@ -1149,7 +1259,12 @@ public final class SampleQueueTest { * @param sampleCount The number of samples to read. */ private void assertReadTestData(Format startFormat, int firstSampleIndex, int sampleCount) { - assertReadTestData(startFormat, firstSampleIndex, sampleCount, 0); + assertReadTestData( + startFormat, + firstSampleIndex, + sampleCount, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ 0); } /** @@ -1161,7 +1276,11 @@ public final class SampleQueueTest { * @param sampleOffsetUs The expected sample offset. */ private void assertReadTestData( - Format startFormat, int firstSampleIndex, int sampleCount, long sampleOffsetUs) { + Format startFormat, + int firstSampleIndex, + int sampleCount, + long sampleOffsetUs, + long decodeOnlyUntilUs) { Format format = adjustFormat(startFormat, sampleOffsetUs); for (int i = firstSampleIndex; i < firstSampleIndex + sampleCount; i++) { // Use equals() on the read side despite using referential equality on the write side, since @@ -1175,9 +1294,11 @@ public final class SampleQueueTest { // If we require the format, we should always read it. assertReadFormat(true, testSampleFormat); // Assert the sample is as expected. + long expectedTimeUs = SAMPLE_TIMESTAMPS[i] + sampleOffsetUs; assertReadSample( - SAMPLE_TIMESTAMPS[i] + sampleOffsetUs, + expectedTimeUs, (SAMPLE_FLAGS[i] & C.BUFFER_FLAG_KEY_FRAME) != 0, + /* isDecodeOnly= */ expectedTimeUs < decodeOnlyUntilUs, /* isEncrypted= */ false, DATA, DATA.length - SAMPLE_OFFSETS[i] - SAMPLE_SIZES[i], @@ -1221,12 +1342,7 @@ public final class SampleQueueTest { private void assertReadNothing(boolean formatRequired) { clearFormatHolderAndInputBuffer(); int result = - sampleQueue.read( - formatHolder, - inputBuffer, - formatRequired, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + sampleQueue.read(formatHolder, inputBuffer, formatRequired, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_NOTHING_READ); // formatHolder should not be populated. assertThat(formatHolder.format).isNull(); @@ -1244,12 +1360,7 @@ public final class SampleQueueTest { private void assertReadEndOfStream(boolean formatRequired) { clearFormatHolderAndInputBuffer(); int result = - sampleQueue.read( - formatHolder, - inputBuffer, - formatRequired, - /* loadingFinished= */ true, - /* decodeOnlyUntilUs= */ 0); + sampleQueue.read(formatHolder, inputBuffer, formatRequired, /* loadingFinished= */ true); assertThat(result).isEqualTo(RESULT_BUFFER_READ); // formatHolder should not be populated. assertThat(formatHolder.format).isNull(); @@ -1270,12 +1381,7 @@ public final class SampleQueueTest { private void assertReadFormat(boolean formatRequired, Format format) { clearFormatHolderAndInputBuffer(); int result = - sampleQueue.read( - formatHolder, - inputBuffer, - formatRequired, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + sampleQueue.read(formatHolder, inputBuffer, formatRequired, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); // formatHolder should be populated. assertThat(formatHolder.format).isEqualTo(format); @@ -1292,6 +1398,7 @@ public final class SampleQueueTest { assertReadSample( ENCRYPTED_SAMPLE_TIMESTAMPS[sampleIndex], isKeyFrame, + /* isDecodeOnly= */ false, isEncrypted, sampleData, /* offset= */ 0, @@ -1304,6 +1411,7 @@ public final class SampleQueueTest { * * @param timeUs The expected buffer timestamp. * @param isKeyFrame The expected keyframe flag. + * @param isDecodeOnly The expected decodeOnly flag. * @param isEncrypted The expected encrypted flag. * @param sampleData An array containing the expected sample data. * @param offset The offset in {@code sampleData} of the expected sample data. @@ -1312,6 +1420,7 @@ public final class SampleQueueTest { private void assertReadSample( long timeUs, boolean isKeyFrame, + boolean isDecodeOnly, boolean isEncrypted, byte[] sampleData, int offset, @@ -1319,18 +1428,14 @@ public final class SampleQueueTest { clearFormatHolderAndInputBuffer(); int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_BUFFER_READ); // formatHolder should not be populated. assertThat(formatHolder.format).isNull(); // inputBuffer should be populated. assertThat(inputBuffer.timeUs).isEqualTo(timeUs); assertThat(inputBuffer.isKeyFrame()).isEqualTo(isKeyFrame); - assertThat(inputBuffer.isDecodeOnly()).isFalse(); + assertThat(inputBuffer.isDecodeOnly()).isEqualTo(isDecodeOnly); assertThat(inputBuffer.isEncrypted()).isEqualTo(isEncrypted); inputBuffer.flip(); assertThat(inputBuffer.data.limit()).isEqualTo(length); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index 58783ad745..02b0dd3b52 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -380,11 +380,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { buffer.clear(); int result = sampleQueue.read( - formatHolder, - buffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, buffer, /* formatRequired= */ false, /* loadingFinished= */ false); if (result == C.RESULT_BUFFER_READ) { buffer.flip(); return buffer; diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 5f0aff46cc..9fb625313a 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -560,8 +560,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } int result = - sampleQueues[sampleQueueIndex].read( - formatHolder, buffer, requireFormat, loadingFinished, lastSeekPositionUs); + sampleQueues[sampleQueueIndex].read(formatHolder, buffer, requireFormat, loadingFinished); if (result == C.RESULT_FORMAT_READ) { Format format = Assertions.checkNotNull(formatHolder.format); if (sampleQueueIndex == primarySampleQueueIndex) { @@ -641,6 +640,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (isPendingReset()) { chunkQueue = Collections.emptyList(); loadPositionUs = pendingResetPositionUs; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setStartTimeUs(pendingResetPositionUs); + } } else { chunkQueue = readOnlyMediaChunks; HlsMediaChunk lastMediaChunk = getLastMediaChunk(); From 48f2b4493645c177a20d2d98fd1b30b6ca55b21a Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 1 Jul 2020 15:00:56 +0100 Subject: [PATCH 0580/1052] Use a passthrough codec if there is an exoMediaCryptoType Even without a drmInitData, a format can still has a DRM. PiperOrigin-RevId: 319217188 --- .../android/exoplayer2/audio/MediaCodecAudioRenderer.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 4f69b03be1..b94865a410 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -219,12 +219,13 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @TunnelingSupport int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; + boolean formatHasDrm = format.drmInitData != null || format.exoMediaCryptoType != null; boolean supportsFormatDrm = supportsFormatDrm(format); - // In passthrough mode, if DRM init data is present we need to use a passthrough decoder to + // In passthrough mode, if a DRM is present we need to use a passthrough decoder to // decrypt the content. For passthrough of clear content we don't need a decoder at all. if (supportsFormatDrm && usePassthrough(format) - && (format.drmInitData == null || MediaCodecUtil.getPassthroughDecoderInfo() != null)) { + && (!formatHasDrm || MediaCodecUtil.getPassthroughDecoderInfo() != null)) { return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) && !audioSink.supportsOutput(format)) From 729ec8a1c656bd9d7a75502b6f5dfc21017447e3 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Wed, 1 Jul 2020 15:39:47 +0100 Subject: [PATCH 0581/1052] Separate DefaultAudioSink state flush into helper method. PiperOrigin-RevId: 319222833 --- .../exoplayer2/audio/DefaultAudioSink.java | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 3d06d3b154..40378e0376 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -1056,30 +1056,8 @@ public final class DefaultAudioSink implements AudioSink { @Override public void flush() { if (isInitialized()) { - submittedPcmBytes = 0; - submittedEncodedFrames = 0; - writtenPcmBytes = 0; - writtenEncodedFrames = 0; - framesPerEncodedSample = 0; - mediaPositionParameters = - new MediaPositionParameters( - getPlaybackSpeed(), - getSkipSilenceEnabled(), - /* mediaTimeUs= */ 0, - /* audioTrackPositionUs= */ 0); - startMediaTimeUs = 0; - afterDrainParameters = null; - mediaPositionParametersCheckpoints.clear(); - trimmingAudioProcessor.resetTrimmedFrameCount(); - flushAudioProcessors(); - inputBuffer = null; - inputBufferAccessUnitCount = 0; - outputBuffer = null; - stoppedAudioTrack = false; - handledEndOfStream = false; - drainingAudioProcessorIndex = C.INDEX_UNSET; - avSyncHeader = null; - bytesUntilNextAvSync = 0; + resetSinkStateForFlush(); + if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } @@ -1125,6 +1103,33 @@ public final class DefaultAudioSink implements AudioSink { // Internal methods. + private void resetSinkStateForFlush() { + submittedPcmBytes = 0; + submittedEncodedFrames = 0; + writtenPcmBytes = 0; + writtenEncodedFrames = 0; + framesPerEncodedSample = 0; + mediaPositionParameters = + new MediaPositionParameters( + getPlaybackSpeed(), + getSkipSilenceEnabled(), + /* mediaTimeUs= */ 0, + /* audioTrackPositionUs= */ 0); + startMediaTimeUs = 0; + afterDrainParameters = null; + mediaPositionParametersCheckpoints.clear(); + inputBuffer = null; + inputBufferAccessUnitCount = 0; + outputBuffer = null; + stoppedAudioTrack = false; + handledEndOfStream = false; + drainingAudioProcessorIndex = C.INDEX_UNSET; + avSyncHeader = null; + bytesUntilNextAvSync = 0; + trimmingAudioProcessor.resetTrimmedFrameCount(); + flushAudioProcessors(); + } + /** Releases {@link #keepSessionIdAudioTrack} asynchronously, if it is non-{@code null}. */ private void releaseKeepSessionIdAudioTrack() { if (keepSessionIdAudioTrack == null) { From 69187523b1f8335ebcccaf804f1be3e79180a14c Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 1 Jul 2020 15:58:30 +0100 Subject: [PATCH 0582/1052] Renaming to make pasthrough modes more explicit Passthrough mode can use a codec or not, but the code only mentioned "passthrough" in most cases, making the specific mode confusing. For example both `MediaCodecRenderer` and it's derived class `MediaCodecAudioRenderer` had a private `passthroughEnabled` field, but they were used for the opposite modes! This change renames all relevant variables/functions to explicit `CodecPassthrough` or `Bypass`. PiperOrigin-RevId: 319225235 --- .../audio/MediaCodecAudioRenderer.java | 20 ++-- .../mediacodec/MediaCodecRenderer.java | 94 +++++++++---------- .../video/MediaCodecVideoRenderer.java | 2 +- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index b94865a410..f8e1b991f2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -84,10 +84,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private final AudioSink audioSink; private int codecMaxInputSize; - private boolean passthroughEnabled; private boolean codecNeedsDiscardChannelsWorkaround; private boolean codecNeedsEosBufferTimestampWorkaround; - @Nullable private Format passthroughCodecFormat; + @Nullable private Format codecPassthroughFormat; @Nullable private Format inputFormat; private long currentPositionUs; private boolean allowFirstBufferPositionDiscontinuity; @@ -299,14 +298,14 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media codecMaxInputSize = getCodecMaxInputSize(codecInfo, format, getStreamFormats()); codecNeedsDiscardChannelsWorkaround = codecNeedsDiscardChannelsWorkaround(codecInfo.name); codecNeedsEosBufferTimestampWorkaround = codecNeedsEosBufferTimestampWorkaround(codecInfo.name); - passthroughEnabled = - MimeTypes.AUDIO_RAW.equals(codecInfo.mimeType) - && !MimeTypes.AUDIO_RAW.equals(format.sampleMimeType); MediaFormat mediaFormat = getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate); codecAdapter.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); // Store the input MIME type if we're using the passthrough codec. - passthroughCodecFormat = passthroughEnabled ? format : null; + boolean codecPassthroughEnabled = + MimeTypes.AUDIO_RAW.equals(codecInfo.mimeType) + && !MimeTypes.AUDIO_RAW.equals(format.sampleMimeType); + codecPassthroughFormat = codecPassthroughEnabled ? format : null; } @Override @@ -388,8 +387,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void configureOutput(Format outputFormat) throws ExoPlaybackException { Format audioSinkInputFormat; - if (passthroughCodecFormat != null) { - @C.Encoding int passthroughEncoding = getPassthroughEncoding(passthroughCodecFormat); + if (codecPassthroughFormat != null) { + @C.Encoding int passthroughEncoding = getPassthroughEncoding(codecPassthroughFormat); // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); audioSinkInputFormat = outputFormat.buildUpon().setEncoding(passthroughEncoding).build(); @@ -426,7 +425,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @Override - protected void onOutputPassthroughFormatChanged(Format outputFormat) throws ExoPlaybackException { + protected void onOutputBypassFormatChanged(Format outputFormat) throws ExoPlaybackException { @C.Encoding int passthroughEncoding = getPassthroughEncoding(outputFormat); // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); @@ -631,7 +630,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media bufferPresentationTimeUs = getLargestQueuedPresentationTimeUs(); } - if (passthroughEnabled && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + if (codecPassthroughFormat != null + && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { // Discard output buffers from the passthrough (raw) decoder containing codec specific data. codec.releaseOutputBuffer(bufferIndex, false); return true; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index a72aed3e75..d50b9b31de 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -346,7 +346,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private final float assumedMinimumCodecOperatingRate; private final DecoderInputBuffer buffer; private final DecoderInputBuffer flagsOnlyBuffer; - private final BatchBuffer passthroughBatchBuffer; + private final BatchBuffer bypassBatchBuffer; private final TimedValueQueue formatQueue; private final ArrayList decodeOnlyPresentationTimestamps; private final MediaCodec.BufferInfo outputBufferInfo; @@ -388,8 +388,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private ByteBuffer outputBuffer; private boolean isDecodeOnlyOutputBuffer; private boolean isLastOutputBuffer; - private boolean passthroughEnabled; - private boolean passthroughDrainAndReinitialize; + private boolean bypassEnabled; + private boolean bypassDrainAndReinitialize; private boolean codecReconfigured; @ReconfigurationState private int codecReconfigurationState; @DrainState private int codecDrainState; @@ -441,7 +441,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamSwitchTimesUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; outputStreamOffsetUs = C.TIME_UNSET; - passthroughBatchBuffer = new BatchBuffer(); + bypassBatchBuffer = new BatchBuffer(); resetCodecStateForRelease(); } @@ -544,14 +544,14 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Nullable MediaCrypto crypto, float codecOperatingRate); - protected final void maybeInitCodecOrPassthrough() throws ExoPlaybackException { - if (codec != null || passthroughEnabled || inputFormat == null) { - // We have a codec or using passthrough, or don't have a format to decide how to render. + protected final void maybeInitCodecOrBypass() throws ExoPlaybackException { + if (codec != null || bypassEnabled || inputFormat == null) { + // We have a codec, are bypassing it, or don't have a format to decide how to render. return; } if (sourceDrmSession == null && usePassthrough(inputFormat)) { - initPassthrough(inputFormat); + initBypass(inputFormat); return; } @@ -699,8 +699,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { inputStreamEnded = false; outputStreamEnded = false; pendingOutputEndOfStream = false; - if (passthroughEnabled) { - passthroughBatchBuffer.flush(); + if (bypassEnabled) { + bypassBatchBuffer.flush(); } else { flushOrReinitializeCodec(); } @@ -743,17 +743,17 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onReset() { try { - disablePassthrough(); + disableBypass(); releaseCodec(); } finally { setSourceDrmSession(null); } } - private void disablePassthrough() { - passthroughDrainAndReinitialize = false; - passthroughBatchBuffer.clear(); - passthroughEnabled = false; + private void disableBypass() { + bypassDrainAndReinitialize = false; + bypassBatchBuffer.clear(); + bypassEnabled = false; } protected void releaseCodec() { @@ -812,10 +812,10 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return; } // We have a format. - maybeInitCodecOrPassthrough(); - if (passthroughEnabled) { - TraceUtil.beginSection("renderPassthrough"); - while (renderPassthrough(positionUs, elapsedRealtimeUs)) {} + maybeInitCodecOrBypass(); + if (bypassEnabled) { + TraceUtil.beginSection("bypassRender"); + while (bypassRender(positionUs, elapsedRealtimeUs)) {} TraceUtil.endSection(); } else if (codec != null) { long renderStartTimeMs = SystemClock.elapsedRealtime(); @@ -846,7 +846,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * This method is a no-op if the codec is {@code null}. * *

        The implementation of this method calls {@link #flushOrReleaseCodec()}, and {@link - * #maybeInitCodecOrPassthrough()} if the codec needs to be re-instantiated. + * #maybeInitCodecOrBypass()} if the codec needs to be re-instantiated. * * @return Whether the codec was released and reinitialized, rather than being flushed. * @throws ExoPlaybackException If an error occurs re-instantiating the codec. @@ -854,7 +854,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected final boolean flushOrReinitializeCodec() throws ExoPlaybackException { boolean released = flushOrReleaseCodec(); if (released) { - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); } return released; } @@ -1051,23 +1051,23 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } /** - * Configures passthrough where no codec is used. Called instead of {@link + * Configures rendering where no codec is used. Called instead of {@link * #configureCodec(MediaCodecInfo, MediaCodecAdapter, Format, MediaCrypto, float)} when no codec - * is used in passthrough. + * is used to render. */ - private void initPassthrough(Format format) { - disablePassthrough(); // In case of transition between 2 passthrough formats. + private void initBypass(Format format) { + disableBypass(); // In case of transition between 2 bypass formats. String mimeType = format.sampleMimeType; if (!MimeTypes.AUDIO_AAC.equals(mimeType) && !MimeTypes.AUDIO_MPEG.equals(mimeType) && !MimeTypes.AUDIO_OPUS.equals(mimeType)) { - // TODO(b/154746451): Batching provokes frame drops in non offload passthrough. - passthroughBatchBuffer.setMaxAccessUnitCount(1); + // TODO(b/154746451): Batching provokes frame drops in non offload. + bypassBatchBuffer.setMaxAccessUnitCount(1); } else { - passthroughBatchBuffer.setMaxAccessUnitCount(BatchBuffer.DEFAULT_BATCH_SIZE_ACCESS_UNITS); + bypassBatchBuffer.setMaxAccessUnitCount(BatchBuffer.DEFAULT_BATCH_SIZE_ACCESS_UNITS); } - passthroughEnabled = true; + bypassEnabled = true; } private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exception { @@ -1405,18 +1405,18 @@ public abstract class MediaCodecRenderer extends BaseRenderer { setSourceDrmSession(formatHolder.drmSession); inputFormat = newFormat; - if (passthroughEnabled) { - passthroughDrainAndReinitialize = true; - return; // Need to drain passthrough first. + if (bypassEnabled) { + bypassDrainAndReinitialize = true; + return; // Need to drain batch buffer first. } if (codec == null) { - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); return; } // We have an existing codec that we may need to reconfigure or re-initialize or release it to - // switch to passthrough. If the existing codec instance is being kept then its operating rate + // switch to bypass. If the existing codec instance is being kept then its operating rate // may need to be updated. if ((sourceDrmSession == null && codecDrmSession != null) @@ -1514,7 +1514,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } /** - * Called when the output {@link Format} changes in passthrough. + * Called when the output {@link Format} changes in bypass mode (no codec used). * *

        The default implementation is a no-op. * @@ -1522,7 +1522,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * @throws ExoPlaybackException Thrown if an error occurs handling the new output media format. */ // TODO(b/154849417): merge with {@link #onOutputFormatChanged(Format)}. - protected void onOutputPassthroughFormatChanged(Format outputFormat) throws ExoPlaybackException { + protected void onOutputBypassFormatChanged(Format outputFormat) throws ExoPlaybackException { // Do nothing. } @@ -1874,7 +1874,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * iteration of the rendering loop. * @param elapsedRealtimeUs {@link SystemClock#elapsedRealtime()} in microseconds, measured at the * start of the current iteration of the rendering loop. - * @param codec The {@link MediaCodec} instance, or null in passthrough mode. + * @param codec The {@link MediaCodec} instance, or null in bypass mode were no codec is used. * @param buffer The output buffer to process. * @param bufferIndex The index of the output buffer. * @param bufferFlags The flags attached to the output buffer. @@ -2003,7 +2003,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private void reinitializeCodec() throws ExoPlaybackException { releaseCodec(); - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); } private boolean isDecodeOnlyBuffer(long presentationTimeUs) { @@ -2081,9 +2081,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * @throws ExoPlaybackException If an error occurred while processing a buffer or handling a * format change. */ - private boolean renderPassthrough(long positionUs, long elapsedRealtimeUs) + private boolean bypassRender(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - BatchBuffer batchBuffer = passthroughBatchBuffer; + BatchBuffer batchBuffer = bypassBatchBuffer; // Let's process the pending buffer if any. Assertions.checkState(!outputStreamEnded); @@ -2112,15 +2112,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } batchBuffer.batchWasConsumed(); - if (passthroughDrainAndReinitialize) { + if (bypassDrainAndReinitialize) { if (!batchBuffer.isEmpty()) { return true; // Drain the batch buffer before propagating the format change. } - disablePassthrough(); // The new format might not be supported in passthrough. - passthroughDrainAndReinitialize = false; - maybeInitCodecOrPassthrough(); - if (!passthroughEnabled) { - return false; // The new format is not supported in passthrough. + disableBypass(); // The new format might require a codec. + bypassDrainAndReinitialize = false; + maybeInitCodecOrBypass(); + if (!bypassEnabled) { + return false; // The new format is not supported in codec bypass. } } @@ -2132,7 +2132,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { if (!batchBuffer.isEmpty() && waitingForFirstSampleInFormat) { // This is the first buffer in a new format, the output format must be updated. outputFormat = Assertions.checkNotNull(inputFormat); - onOutputPassthroughFormatChanged(outputFormat); + onOutputBypassFormatChanged(outputFormat); waitingForFirstSampleInFormat = false; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 6e0cb7361d..d06375e68c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -515,7 +515,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { setOutputSurfaceV23(codec, surface); } else { releaseCodec(); - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); } } if (surface != null && surface != dummySurface) { From 541568386baa0b1aeebd7afb6d86a494b044144b Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 1 Jul 2020 16:33:16 +0100 Subject: [PATCH 0583/1052] Merge `onOutputCodecBypassFormatChanged` and `onOutputFormatChanged` PiperOrigin-RevId: 319230328 --- .../audio/MediaCodecAudioRenderer.java | 45 ++++++++----------- .../mediacodec/MediaCodecRenderer.java | 15 +------ 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index f8e1b991f2..b2bd454440 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -387,11 +387,11 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void configureOutput(Format outputFormat) throws ExoPlaybackException { Format audioSinkInputFormat; - if (codecPassthroughFormat != null) { - @C.Encoding int passthroughEncoding = getPassthroughEncoding(codecPassthroughFormat); - // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. - Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); - audioSinkInputFormat = outputFormat.buildUpon().setEncoding(passthroughEncoding).build(); + @Nullable int[] channelMap = null; + if (codecPassthroughFormat != null) { // Raw codec passthrough + audioSinkInputFormat = getFormatWithEncodingForPassthrough(codecPassthroughFormat); + } else if (getCodec() == null) { // Codec bypass passthrough + audioSinkInputFormat = getFormatWithEncodingForPassthrough(outputFormat); } else { MediaFormat mediaFormat = getCodec().getOutputFormat(); @C.Encoding int encoding; @@ -407,14 +407,13 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media .setChannelCount(mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)) .setSampleRate(mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)) .build(); - } - @Nullable int[] channelMap = null; - if (codecNeedsDiscardChannelsWorkaround - && audioSinkInputFormat.channelCount == 6 - && outputFormat.channelCount < 6) { - channelMap = new int[outputFormat.channelCount]; - for (int i = 0; i < outputFormat.channelCount; i++) { - channelMap[i] = i; + if (codecNeedsDiscardChannelsWorkaround + && audioSinkInputFormat.channelCount == 6 + && outputFormat.channelCount < 6) { + channelMap = new int[outputFormat.channelCount]; + for (int i = 0; i < outputFormat.channelCount; i++) { + channelMap[i] = i; + } } } try { @@ -424,19 +423,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } } - @Override - protected void onOutputBypassFormatChanged(Format outputFormat) throws ExoPlaybackException { - @C.Encoding int passthroughEncoding = getPassthroughEncoding(outputFormat); - // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. - Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); - Format format = outputFormat.buildUpon().setEncoding(passthroughEncoding).build(); - try { - audioSink.configure(format, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); - } catch (AudioSink.ConfigurationException e) { - throw createRendererException(e, outputFormat); - } - } - /** * Returns the {@link C.Encoding} constant to use for passthrough of the given format, or {@link * C#ENCODING_INVALID} if passthrough is not possible. @@ -799,6 +785,13 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } } + private Format getFormatWithEncodingForPassthrough(Format outputFormat) { + @C.Encoding int passthroughEncoding = getPassthroughEncoding(outputFormat); + // TODO(b/112299307): Passthrough can have become unavailable since usePassthrough was called. + Assertions.checkState(passthroughEncoding != C.ENCODING_INVALID); + return outputFormat.buildUpon().setEncoding(passthroughEncoding).build(); + } + /** * Returns whether the device's decoders are known to not support setting the codec operating * rate. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index d50b9b31de..12aacaed51 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -1513,19 +1513,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { // Do nothing. } - /** - * Called when the output {@link Format} changes in bypass mode (no codec used). - * - *

        The default implementation is a no-op. - * - * @param outputFormat The new output {@link MediaFormat}. - * @throws ExoPlaybackException Thrown if an error occurs handling the new output media format. - */ - // TODO(b/154849417): merge with {@link #onOutputFormatChanged(Format)}. - protected void onOutputBypassFormatChanged(Format outputFormat) throws ExoPlaybackException { - // Do nothing. - } - /** * Handles supplemental data associated with an input buffer. * @@ -2132,7 +2119,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { if (!batchBuffer.isEmpty() && waitingForFirstSampleInFormat) { // This is the first buffer in a new format, the output format must be updated. outputFormat = Assertions.checkNotNull(inputFormat); - onOutputBypassFormatChanged(outputFormat); + onOutputFormatChanged(outputFormat); waitingForFirstSampleInFormat = false; } From 513b268558f7c01240a3656518be75e14983361c Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 1 Jul 2020 18:13:07 +0100 Subject: [PATCH 0584/1052] Detect invalid frames early in passthrough/offload Without checking if getFramesPerEncodedSample fails, the frame count becomes negative which leads to hard to debug errors. PiperOrigin-RevId: 319247618 --- .../google/android/exoplayer2/audio/DefaultAudioSink.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 40378e0376..9beae27c89 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -1402,7 +1402,11 @@ public final class DefaultAudioSink implements AudioSink { switch (encoding) { case C.ENCODING_MP3: int headerDataInBigEndian = Util.getBigEndianInt(buffer, buffer.position()); - return MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataInBigEndian); + int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataInBigEndian); + if (frameCount == C.LENGTH_UNSET) { + throw new IllegalArgumentException(); + } + return frameCount; case C.ENCODING_AAC_LC: return AacUtil.AAC_LC_AUDIO_SAMPLE_COUNT; case C.ENCODING_AAC_HE_V1: From cf3fbdd19b895942f2f149f2fb8ae833b210d11c Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 2 Jul 2020 10:49:26 +0100 Subject: [PATCH 0585/1052] Rename createDefaultLoadControl() to build() The standard, least surprising name, for a builder's method that builds the object is `build`. PiperOrigin-RevId: 319379622 --- .../exoplayer2/DefaultLoadControl.java | 34 +++++++++++-------- .../exoplayer2/DefaultLoadControlTest.java | 22 ++++++------ 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java index 3be234f3f9..5cbe394f1d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java @@ -103,7 +103,7 @@ public class DefaultLoadControl implements LoadControl { private boolean prioritizeTimeOverSizeThresholds; private int backBufferDurationMs; private boolean retainBackBufferFromKeyframe; - private boolean createDefaultLoadControlCalled; + private boolean buildCalled; /** Constructs a new instance. */ public Builder() { @@ -122,10 +122,10 @@ public class DefaultLoadControl implements LoadControl { * * @param allocator The {@link DefaultAllocator}. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setAllocator(DefaultAllocator allocator) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); this.allocator = allocator; return this; } @@ -143,14 +143,14 @@ public class DefaultLoadControl implements LoadControl { * for playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be * caused by buffer depletion rather than a user action. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setBufferDurationsMs( int minBufferMs, int maxBufferMs, int bufferForPlaybackMs, int bufferForPlaybackAfterRebufferMs) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0"); assertGreaterOrEqual( bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0"); @@ -174,10 +174,10 @@ public class DefaultLoadControl implements LoadControl { * * @param targetBufferBytes The target buffer size in bytes. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setTargetBufferBytes(int targetBufferBytes) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); this.targetBufferBytes = targetBufferBytes; return this; } @@ -189,10 +189,10 @@ public class DefaultLoadControl implements LoadControl { * @param prioritizeTimeOverSizeThresholds Whether the load control prioritizes buffer time * constraints over buffer size constraints. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSizeThresholds) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; return this; } @@ -205,20 +205,26 @@ public class DefaultLoadControl implements LoadControl { * @param retainBackBufferFromKeyframe Whether the back buffer is retained from the previous * keyframe. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setBackBuffer(int backBufferDurationMs, boolean retainBackBufferFromKeyframe) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0"); this.backBufferDurationMs = backBufferDurationMs; this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; return this; } - /** Creates a {@link DefaultLoadControl}. */ + /** @deprecated use {@link #build} instead. */ + @Deprecated public DefaultLoadControl createDefaultLoadControl() { - Assertions.checkState(!createDefaultLoadControlCalled); - createDefaultLoadControlCalled = true; + return build(); + } + + /** Creates a {@link DefaultLoadControl}. */ + public DefaultLoadControl build() { + Assertions.checkState(!buildCalled); + buildCalled = true; if (allocator == null) { allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java index f7065fbbc5..b00da4390a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java @@ -47,7 +47,7 @@ public class DefaultLoadControlTest { @Test public void shouldContinueLoading_untilMaxBufferExceeded() { - createDefaultLoadControl(); + build(); assertThat( loadControl.shouldContinueLoading( @@ -68,7 +68,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) .isFalse(); @@ -91,7 +91,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) .isFalse(); @@ -111,7 +111,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); makeSureTargetBufferBytesReached(); assertThat( @@ -132,7 +132,7 @@ public class DefaultLoadControlTest { public void shouldContinueLoading_withTargetBufferBytesReachedAndNotPrioritizeTimeOverSize_returnsTrueAsSoonAsTargetBufferReached() { builder.setPrioritizeTimeOverSizeThresholds(false); - createDefaultLoadControl(); + build(); // Put loadControl in buffering state. assertThat( @@ -162,7 +162,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); // At normal playback speed, we stop buffering when the buffer reaches the minimum. assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED)) @@ -176,7 +176,7 @@ public class DefaultLoadControlTest { @Test public void shouldNotContinueLoadingWithMaxBufferReached_inFastPlayback() { - createDefaultLoadControl(); + build(); assertThat( loadControl.shouldContinueLoading( @@ -186,7 +186,7 @@ public class DefaultLoadControlTest { @Test public void startsPlayback_whenMinBufferSizeReached() { - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldStartPlayback(MIN_BUFFER_US, SPEED, /* rebuffering= */ false)) .isTrue(); @@ -194,7 +194,7 @@ public class DefaultLoadControlTest { @Test public void shouldContinueLoading_withNoSelectedTracks_returnsTrue() { - loadControl = builder.createDefaultLoadControl(); + loadControl = builder.build(); loadControl.onTracksSelected(new Renderer[0], TrackGroupArray.EMPTY, new TrackSelectionArray()); assertThat( @@ -203,9 +203,9 @@ public class DefaultLoadControlTest { .isTrue(); } - private void createDefaultLoadControl() { + private void build() { builder.setAllocator(allocator).setTargetBufferBytes(TARGET_BUFFER_BYTES); - loadControl = builder.createDefaultLoadControl(); + loadControl = builder.build(); loadControl.onTracksSelected(new Renderer[0], null, null); } From 31efd5387bc8cde076baf03a2054f08f6f1c42c7 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 2 Jul 2020 14:07:55 +0100 Subject: [PATCH 0586/1052] Set MediaPeriodId in MergingMediaPeriodTest PiperOrigin-RevId: 319399717 --- .../android/exoplayer2/source/MergingMediaPeriodTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java index d201782b53..20df898cb8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.testutil.FakeMediaPeriod; import com.google.android.exoplayer2.trackselection.FixedTrackSelection; @@ -143,7 +144,12 @@ public final class MergingMediaPeriodTest { } mediaPeriods[i] = new FakeMediaPeriodWithSelectTracksPosition( - new TrackGroupArray(trackGroups), new EventDispatcher()); + new TrackGroupArray(trackGroups), + new EventDispatcher() + .withParameters( + /* windowIndex= */ i, + new MediaPeriodId(/* periodUid= */ i), + /* mediaTimeOffsetMs= */ 0)); } MergingMediaPeriod mergingMediaPeriod = new MergingMediaPeriod( From 8d131cad7b314a3f0f49e7de47fb5a13a224da1a Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 2 Jul 2020 16:05:42 +0100 Subject: [PATCH 0587/1052] Fix flaky AsynchronousMediaCodecAdapterTest The test was incorrectly assuming that with LooperMode.PAUSE, HandlerThread instances needed explicit calls to execute tasks. This commit fixes the test flakiness by manually pausing the HandlerThead when needed. PiperOrigin-RevId: 319411552 --- .../AsynchronousMediaCodecAdapter.java | 14 --- .../AsynchronousMediaCodecAdapterTest.java | 94 ++++++++++++------- 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java index f7c89b89c7..84ff985495 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java @@ -21,7 +21,6 @@ import android.media.MediaCrypto; import android.media.MediaFormat; import android.os.Handler; import android.os.HandlerThread; -import android.os.Looper; import android.view.Surface; import androidx.annotation.GuardedBy; import androidx.annotation.IntDef; @@ -251,19 +250,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } } - @VisibleForTesting - /* package */ void onMediaCodecError(IllegalStateException e) { - synchronized (lock) { - mediaCodecAsyncCallback.onMediaCodecError(e); - } - } - - @VisibleForTesting - @Nullable - /* package */ Looper getLooper() { - return handlerThread.getLooper(); - } - private void onFlushCompleted() { synchronized (lock) { onFlushCompletedSynchronized(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java index 9a3596d518..ea83e17905 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java @@ -27,11 +27,13 @@ import android.os.HandlerThread; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import java.io.IOException; +import java.lang.reflect.Constructor; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.LooperMode; +import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link AsynchronousMediaCodecAdapter}. */ @LooperMode(PAUSED) @@ -66,12 +68,11 @@ public class AsynchronousMediaCodecAdapterTest { public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + // After adapter.start(), the ShadowMediaCodec offers one input buffer. We pause the looper so + // that the buffer is not propagated to the adapter. + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); - // After start(), the ShadowMediaCodec offers one input buffer, which is available only if we - // progress the adapter's looper. We don't progress the looper so that the buffer is not - // available. - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @@ -79,11 +80,10 @@ public class AsynchronousMediaCodecAdapterTest { public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); - adapter.start(); - // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we - // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + // After start(), the ShadowMediaCodec offers input buffer 0. We advance the looper to make sure + // and messages have been propagated to the adapter. + shadowOf(handlerThread.getLooper()).idle(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @@ -93,12 +93,12 @@ public class AsynchronousMediaCodecAdapterTest { adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we - // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); - // Flush enqueues a task in the looper, but we won't progress the looper to leave flush() - // in a pending state. + // After adapter.start(), the ShadowMediaCodec offers input buffer 0. We run all currently + // enqueued messages and pause the looper so that flush is not completed. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); + shadowLooper.pause(); adapter.flush(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); @@ -109,26 +109,29 @@ public class AsynchronousMediaCodecAdapterTest { adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we - // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + // After adapter.start(), the ShadowMediaCodec offers input buffer 0. We advance the looper to + // make sure all messages have been propagated to the adapter. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); adapter.flush(); // Progress the looper to complete flush(): the adapter should call codec.start(), triggering // the ShadowMediaCodec to offer input buffer 0. - shadowOf(adapter.getLooper()).idle(); + shadowLooper.idle(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @Test - public void dequeueInputBufferIndex_withMediaCodecError_throwsException() { + public void dequeueInputBufferIndex_withMediaCodecError_throwsException() throws Exception { adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + // Pause the looper so that we interact with the adapter from this thread only. + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); // Set an error directly on the adapter (not through the looper). - adapter.onMediaCodecError(new IllegalStateException("error from codec")); + adapter.onError(codec, createCodecException()); assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); } @@ -139,8 +142,9 @@ public class AsynchronousMediaCodecAdapterTest { createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we - // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + // progress the adapter's looper. We progress the looper so that we call shutdown() on a + // non-empty adapter. + shadowOf(handlerThread.getLooper()).idle(); adapter.shutdown(); @@ -153,8 +157,9 @@ public class AsynchronousMediaCodecAdapterTest { createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - // After start(), the ShadowMediaCodec offers an output format change. - shadowOf(adapter.getLooper()).idle(); + // After start(), the ShadowMediaCodec offers an output format change. We progress the looper + // so that the format change is propagated to the adapter. + shadowOf(handlerThread.getLooper()).idle(); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); @@ -170,12 +175,13 @@ public class AsynchronousMediaCodecAdapterTest { adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); int index = adapter.dequeueInputBufferIndex(); adapter.queueInputBuffer(index, 0, 0, 0, 0); // Progress the looper so that the ShadowMediaCodec processes the input buffer. - shadowOf(adapter.getLooper()).idle(); + shadowLooper.idle(); // The ShadowMediaCodec will first offer an output format and then the output buffer. assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) @@ -192,10 +198,12 @@ public class AsynchronousMediaCodecAdapterTest { adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); - // Flush enqueues a task in the looper, but we won't progress the looper to leave flush() - // in a pending state. + // Flush enqueues a task in the looper, but we will pause the looper to leave flush() + // in an incomplete state. + shadowLooper.pause(); adapter.flush(); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) @@ -203,13 +211,15 @@ public class AsynchronousMediaCodecAdapterTest { } @Test - public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() { + public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() throws Exception { + // Pause the looper so that we interact with the adapter from this thread only. adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); // Set an error directly on the adapter. - adapter.onMediaCodecError(new IllegalStateException("error from codec")); + adapter.onError(codec, createCodecException()); assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); } @@ -221,12 +231,13 @@ public class AsynchronousMediaCodecAdapterTest { adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); int index = adapter.dequeueInputBufferIndex(); adapter.queueInputBuffer(index, 0, 0, 0, 0); // Progress the looper so that the ShadowMediaCodec processes the input buffer. - shadowOf(adapter.getLooper()).idle(); + shadowLooper.idle(); adapter.shutdown(); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) @@ -237,6 +248,9 @@ public class AsynchronousMediaCodecAdapterTest { public void getOutputFormat_withoutFormatReceived_throwsException() { adapter.configure( createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + // After start() the ShadowMediaCodec offers an output format change. Pause the looper so that + // the format change is not propagated to the adapter. + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); @@ -249,7 +263,7 @@ public class AsynchronousMediaCodecAdapterTest { adapter.start(); // After start(), the ShadowMediaCodec offers an output format, which is available only if we // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + shadowOf(handlerThread.getLooper()).idle(); // Add another format directly on the adapter. adapter.onOutputFormatChanged(codec, createMediaFormat("format2")); @@ -273,13 +287,14 @@ public class AsynchronousMediaCodecAdapterTest { adapter.start(); // After start(), the ShadowMediaCodec offers an output format, which is available only if we // progress the adapter's looper. - shadowOf(adapter.getLooper()).idle(); + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); adapter.dequeueOutputBufferIndex(bufferInfo); MediaFormat outputFormat = adapter.getOutputFormat(); // Flush the adapter and progress the looper so that flush is completed. adapter.flush(); - shadowOf(adapter.getLooper()).idle(); + shadowLooper.idle(); assertThat(adapter.getOutputFormat()).isEqualTo(outputFormat); } @@ -290,6 +305,15 @@ public class AsynchronousMediaCodecAdapterTest { return format; } + /** Reflectively create a {@link MediaCodec.CodecException}. */ + private static MediaCodec.CodecException createCodecException() throws Exception { + Constructor constructor = + MediaCodec.CodecException.class.getDeclaredConstructor( + Integer.TYPE, Integer.TYPE, String.class); + return constructor.newInstance( + /* errorCode= */ 0, /* actionCode= */ 0, /* detailMessage= */ "error from codec"); + } + private static class TestHandlerThread extends HandlerThread { private boolean quit; From 752fe1b6798808acae1db24e75c40ee22596c620 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 2 Jul 2020 17:34:10 +0100 Subject: [PATCH 0588/1052] Allow FakeMediaSource to specify the FakeSampleStream data PiperOrigin-RevId: 319420451 --- .../android/exoplayer2/ExoPlayerTest.java | 72 ++++-- .../analytics/AnalyticsCollectorTest.java | 59 ++++- .../audio/DecoderAudioRendererTest.java | 9 +- .../audio/MediaCodecAudioRendererTest.java | 54 ++-- .../metadata/MetadataRendererTest.java | 10 +- .../offline/DownloadHelperTest.java | 4 +- .../source/MergingMediaPeriodTest.java | 65 +++-- .../video/DecoderVideoRendererTest.java | 52 ++-- .../video/MediaCodecVideoRendererTest.java | 138 +++++----- .../testutil/FakeAdaptiveMediaPeriod.java | 9 +- .../testutil/FakeAdaptiveMediaSource.java | 8 +- .../exoplayer2/testutil/FakeMediaPeriod.java | 98 +++++-- .../exoplayer2/testutil/FakeMediaSource.java | 37 ++- .../exoplayer2/testutil/FakeSampleStream.java | 242 ++++++++++-------- .../exoplayer2/testutil/FakeTimeline.java | 5 +- 15 files changed, 534 insertions(+), 328 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 30af89dd08..9bcbce834a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertThrows; @@ -617,7 +619,13 @@ public final class ExoPlayerTest { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + eventDispatcher, + drmSessionManager, + /* deferOnPrepared= */ false); mediaPeriod.setSeekToUsOffset(10); return mediaPeriod; } @@ -653,7 +661,11 @@ public final class ExoPlayerTest { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + eventDispatcher); mediaPeriod.setDiscontinuityPositionUs(10); return mediaPeriod; } @@ -680,7 +692,11 @@ public final class ExoPlayerTest { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + eventDispatcher); // Set a discontinuity at the position this period is supposed to start at anyway. mediaPeriod.setDiscontinuityPositionUs( timeline.getWindow(/* windowIndex= */ 0, new Window()).positionInFirstPeriodUs); @@ -929,8 +945,9 @@ public final class ExoPlayerTest { fakeMediaPeriodHolder[0] = new FakeMediaPeriod( trackGroupArray, - drmSessionManager, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, eventDispatcher, + drmSessionManager, /* deferOnPrepared= */ true); createPeriodCalledCountDownLatch.countDown(); return fakeMediaPeriodHolder[0]; @@ -980,8 +997,9 @@ public final class ExoPlayerTest { fakeMediaPeriodHolder[0] = new FakeMediaPeriod( trackGroupArray, - drmSessionManager, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, eventDispatcher, + drmSessionManager, /* deferOnPrepared= */ true); createPeriodCalledCountDownLatch.countDown(); return fakeMediaPeriodHolder[0]; @@ -3708,7 +3726,10 @@ public final class ExoPlayerTest { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { + return new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + eventDispatcher) { @Override public long getBufferedPositionUs() { // Pretend not to have buffered data yet. @@ -6410,7 +6431,10 @@ public final class ExoPlayerTest { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { + return new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + eventDispatcher) { private final List allocations = new ArrayList<>(); @@ -6486,7 +6510,12 @@ public final class ExoPlayerTest { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, drmSessionManager, eventDispatcher) { + return new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + eventDispatcher, + drmSessionManager, + /* deferOnPrepared= */ false) { private Loader loader = new Loader("oomLoader"); @Override @@ -6505,14 +6534,13 @@ public final class ExoPlayerTest { // Create 3 samples without end of stream signal to test that all 3 samples are // still played before the exception is thrown. return new FakeSampleStream( - selection.getSelectedFormat(), drmSessionManager, eventDispatcher, - positionUs, - /* timeUsIncrement= */ 0, - new FakeSampleStream.FakeSampleStreamItem(new byte[] {0}), - new FakeSampleStream.FakeSampleStreamItem(new byte[] {0}), - new FakeSampleStream.FakeSampleStreamItem(new byte[] {0})) { + selection.getSelectedFormat(), + ImmutableList.of( + oneByteSample(positionUs), + oneByteSample(positionUs), + oneByteSample(positionUs))) { @Override public void maybeThrowError() throws IOException { @@ -6676,10 +6704,21 @@ public final class ExoPlayerTest { /* windowOffsetInFirstPeriodUs= */ 1_234_567, AdPlaybackState.NONE)); ExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + long firstSampleTimeUs = 4_567_890 + 1_234_567; FakeMediaSource firstMediaSource = - new FakeMediaSource(/* timeline= */ null, ExoPlayerTestRunner.VIDEO_FORMAT); + new FakeMediaSource( + /* timeline= */ null, + DrmSessionManager.DUMMY, + (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of(oneByteSample(firstSampleTimeUs), END_OF_STREAM_ITEM), + ExoPlayerTestRunner.VIDEO_FORMAT); FakeMediaSource secondMediaSource = - new FakeMediaSource(timelineWithOffsets, ExoPlayerTestRunner.VIDEO_FORMAT); + new FakeMediaSource( + timelineWithOffsets, + DrmSessionManager.DUMMY, + (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of(oneByteSample(firstSampleTimeUs), END_OF_STREAM_ITEM), + ExoPlayerTestRunner.VIDEO_FORMAT); player.setMediaSources(ImmutableList.of(firstMediaSource, secondMediaSource)); // Start playback and wait until player is idly waiting for an update of the first source. @@ -6697,7 +6736,6 @@ public final class ExoPlayerTest { assertThat(rendererStreamOffsetsUs).hasSize(2); assertThat(firstBufferTimesUsWithOffset).hasSize(2); // Assert that the offsets and buffer times match the expected sample time. - long firstSampleTimeUs = 4_567_890 + 1_234_567; assertThat(firstBufferTimesUsWithOffset.get(0)) .isEqualTo(rendererStreamOffsetsUs.get(0) + firstSampleTimeUs); assertThat(firstBufferTimesUsWithOffset.get(1)) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index d25cac6806..9916991705 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.analytics; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import android.view.Surface; @@ -57,6 +59,7 @@ import com.google.android.exoplayer2.testutil.FakeVideoRenderer; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; @@ -822,10 +825,10 @@ public final class AnalyticsCollectorTest { .containsExactly(window0Period1Seq0, period1Seq0) .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0) + .containsExactly(window0Period1Seq0, window1Period0Seq1) .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0) + .containsExactly(window0Period1Seq0, window1Period0Seq1) .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(window0Period1Seq0, period1Seq0) @@ -926,14 +929,15 @@ public final class AnalyticsCollectorTest { @Test public void adPlayback() throws Exception { - long contentDurationsUs = 10 * C.MICROS_PER_SECOND; + long contentDurationsUs = 11 * C.MICROS_PER_SECOND; + long windowOffsetInFirstPeriodUs = + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; AtomicReference adPlaybackState = new AtomicReference<>( FakeTimeline.createAdPlaybackState( /* adsPerAdGroup= */ 1, /* adGroupTimesUs...= */ - 0, - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US - + 5 * C.MICROS_PER_SECOND, + windowOffsetInFirstPeriodUs, + windowOffsetInFirstPeriodUs + 5 * C.MICROS_PER_SECOND, C.TIME_END_OF_SOURCE)); AtomicInteger playedAdCount = new AtomicInteger(0); Timeline adTimeline = @@ -946,7 +950,23 @@ public final class AnalyticsCollectorTest { contentDurationsUs, adPlaybackState.get())); FakeMediaSource fakeMediaSource = - new FakeMediaSource(adTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); + new FakeMediaSource( + adTimeline, + DrmSessionManager.DUMMY, + (unusedFormat, mediaPeriodId) -> { + if (mediaPeriodId.isAd()) { + return ImmutableList.of(oneByteSample(/* timeUs= */ 0), END_OF_STREAM_ITEM); + } else { + // Provide a single sample before and after the midroll ad and another after the + // postroll. + return ImmutableList.of( + oneByteSample(windowOffsetInFirstPeriodUs + C.MICROS_PER_SECOND), + oneByteSample(windowOffsetInFirstPeriodUs + 6 * C.MICROS_PER_SECOND), + oneByteSample(windowOffsetInFirstPeriodUs + contentDurationsUs), + END_OF_STREAM_ITEM); + } + }, + ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .executeRunnable( @@ -965,7 +985,7 @@ public final class AnalyticsCollectorTest { adPlaybackState .get() .withPlayedAd( - playedAdCount.getAndIncrement(), + /* adGroupIndex= */ playedAdCount.getAndIncrement(), /* adIndexInAdGroup= */ 0)); fakeMediaSource.setNewSourceInfo( new FakeTimeline( @@ -974,7 +994,7 @@ public final class AnalyticsCollectorTest { /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, - /* durationUs =*/ 10 * C.MICROS_PER_SECOND, + contentDurationsUs, adPlaybackState.get())), /* sendManifestLoadEvents= */ false); } @@ -1185,6 +1205,8 @@ public final class AnalyticsCollectorTest { @Test public void seekAfterMidroll() throws Exception { + long windowOffsetInFirstPeriodUs = + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; Timeline adTimeline = new FakeTimeline( new TimelineWindowDefinition( @@ -1195,10 +1217,23 @@ public final class AnalyticsCollectorTest { 10 * C.MICROS_PER_SECOND, FakeTimeline.createAdPlaybackState( /* adsPerAdGroup= */ 1, /* adGroupTimesUs...= */ - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US - + 5 * C.MICROS_PER_SECOND))); + windowOffsetInFirstPeriodUs + 5 * C.MICROS_PER_SECOND))); FakeMediaSource fakeMediaSource = - new FakeMediaSource(adTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); + new FakeMediaSource( + adTimeline, + DrmSessionManager.DUMMY, + (unusedFormat, mediaPeriodId) -> { + if (mediaPeriodId.isAd()) { + return ImmutableList.of(oneByteSample(/* timeUs= */ 0), END_OF_STREAM_ITEM); + } else { + // Provide a sample before the midroll and another after the seek point below (6s). + return ImmutableList.of( + oneByteSample(windowOffsetInFirstPeriodUs + C.MICROS_PER_SECOND), + oneByteSample(windowOffsetInFirstPeriodUs + 7 * C.MICROS_PER_SECOND), + END_OF_STREAM_ITEM); + } + }, + ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java index bfc657aaf4..b141f1ac99 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.RendererCapabilities.ADAPTIVE_NOT_SE import static com.google.android.exoplayer2.RendererCapabilities.FORMAT_HANDLED; import static com.google.android.exoplayer2.RendererCapabilities.TUNNELING_NOT_SUPPORTED; import static com.google.android.exoplayer2.RendererCapabilities.TUNNELING_SUPPORTED; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -33,9 +34,11 @@ import com.google.android.exoplayer2.decoder.DecoderException; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -103,7 +106,11 @@ public class DecoderAudioRendererTest { audioRenderer.enable( RendererConfiguration.DEFAULT, new Format[] {FORMAT}, - new FakeSampleStream(FORMAT, /* eventDispatcher= */ null, /* shouldOutputSample= */ false), + new FakeSampleStream( + DrmSessionManager.DUMMY, + /* eventDispatcher= */ null, + FORMAT, + ImmutableList.of(END_OF_STREAM_ITEM)), /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java index 9ba8c9f2d2..21c5086c5a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.audio; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.format; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -33,8 +36,8 @@ import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.testutil.FakeSampleStream; -import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import java.util.Collections; import java.util.List; import org.junit.Before; @@ -108,19 +111,18 @@ public class MediaCodecAudioRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ AUDIO_AAC, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(changedFormat), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ AUDIO_AAC, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), + oneByteSample(/* timeUs= */ 50), + oneByteSample(/* timeUs= */ 100), + format(changedFormat), + oneByteSample(/* timeUs= */ 150), + oneByteSample(/* timeUs= */ 200), + oneByteSample(/* timeUs= */ 250), + END_OF_STREAM_ITEM)); mediaCodecAudioRenderer.enable( RendererConfiguration.DEFAULT, @@ -156,19 +158,18 @@ public class MediaCodecAudioRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ AUDIO_AAC, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(changedFormat), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ AUDIO_AAC, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), + oneByteSample(/* timeUs= */ 50), + oneByteSample(/* timeUs= */ 100), + format(changedFormat), + oneByteSample(/* timeUs= */ 150), + oneByteSample(/* timeUs= */ 200), + oneByteSample(/* timeUs= */ 250), + END_OF_STREAM_ITEM)); mediaCodecAudioRenderer.enable( RendererConfiguration.DEFAULT, @@ -224,13 +225,10 @@ public class MediaCodecAudioRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ AUDIO_AAC, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ AUDIO_AAC, + ImmutableList.of(oneByteSample(/* timeUs= */ 0), END_OF_STREAM_ITEM)); exceptionThrowingRenderer.enable( RendererConfiguration.DEFAULT, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java index 1d1cb20b34..d664964888 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java @@ -32,6 +32,7 @@ import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamI import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import com.google.common.primitives.Bytes; import java.util.ArrayList; import java.util.Collections; @@ -145,13 +146,12 @@ public class MetadataRendererTest { renderer.replaceStream( new Format[] {EMSG_FORMAT}, new FakeSampleStream( - EMSG_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 0, - new FakeSampleStreamItem(input), - FakeSampleStreamItem.END_OF_STREAM_ITEM), + EMSG_FORMAT, + ImmutableList.of( + FakeSampleStreamItem.sample(/* timeUs= */ 0, /* flags= */ 0, input), + FakeSampleStreamItem.END_OF_STREAM_ITEM)), /* offsetUs= */ 0L); renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); // Read the format renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); // Read the data diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index b8edff6f60..294236edbb 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -503,14 +503,14 @@ public class DownloadHelperTest { int periodIndex = TEST_TIMELINE.getIndexOfPeriod(id.periodUid); return new FakeMediaPeriod( trackGroupArrays[periodIndex], + TEST_TIMELINE.getWindow(0, new Timeline.Window()).positionInFirstPeriodUs, new EventDispatcher() .withParameters(/* windowIndex= */ 0, id, /* mediaTimeOffsetMs= */ 0)) { @Override public List getStreamKeys(List trackSelections) { List result = new ArrayList<>(); for (TrackSelection trackSelection : trackSelections) { - int groupIndex = - trackGroupArrays[periodIndex].indexOf(trackSelection.getTrackGroup()); + int groupIndex = trackGroupArrays[periodIndex].indexOf(trackSelection.getTrackGroup()); for (int i = 0; i < trackSelection.length(); i++) { result.add( new StreamKey(periodIndex, groupIndex, trackSelection.getIndexInTrackGroup(i))); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java index 20df898cb8..a88fbed27f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -22,11 +24,13 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.testutil.FakeMediaPeriod; import com.google.android.exoplayer2.trackselection.FixedTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.common.collect.ImmutableList; import java.util.concurrent.CountDownLatch; import org.checkerframework.checker.nullness.compatqual.NullableType; import org.junit.Test; @@ -47,8 +51,10 @@ public final class MergingMediaPeriodTest { public void getTrackGroups_returnsAllChildTrackGroups() throws Exception { MergingMediaPeriod mergingMediaPeriod = prepareMergingPeriod( - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat11, childFormat12), - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat21, childFormat22)); + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat11, childFormat12), + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat21, childFormat22)); assertThat(mergingMediaPeriod.getTrackGroups().length).isEqualTo(4); assertThat(mergingMediaPeriod.getTrackGroups().get(0).getFormat(0)).isEqualTo(childFormat11); @@ -61,8 +67,10 @@ public final class MergingMediaPeriodTest { public void selectTracks_createsSampleStreamsFromChildPeriods() throws Exception { MergingMediaPeriod mergingMediaPeriod = prepareMergingPeriod( - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat11, childFormat12), - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat21, childFormat22)); + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat11, childFormat12), + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat21, childFormat22)); TrackSelection selectionForChild1 = new FixedTrackSelection(mergingMediaPeriod.getTrackGroups().get(1), /* track= */ 0); @@ -97,8 +105,16 @@ public final class MergingMediaPeriodTest { throws Exception { MergingMediaPeriod mergingMediaPeriod = prepareMergingPeriod( - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat11, childFormat12), - new MergingPeriodDefinition(/* timeOffsetUs= */ -3000, childFormat21, childFormat22)); + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, + /* singleSampleTimeUs= */ 123_000, + childFormat11, + childFormat12), + new MergingPeriodDefinition( + /* timeOffsetUs= */ -3000, + /* singleSampleTimeUs= */ 456_000, + childFormat21, + childFormat22)); TrackSelection selectionForChild1 = new FixedTrackSelection(mergingMediaPeriod.getTrackGroups().get(0), /* track= */ 0); @@ -122,14 +138,14 @@ public final class MergingMediaPeriodTest { assertThat(childMediaPeriod1.selectTracksPositionUs).isEqualTo(0); assertThat(streams[0].readData(formatHolder, inputBuffer, /* formatRequired= */ false)) .isEqualTo(C.RESULT_BUFFER_READ); - assertThat(inputBuffer.timeUs).isEqualTo(0L); + assertThat(inputBuffer.timeUs).isEqualTo(123_000L); FakeMediaPeriodWithSelectTracksPosition childMediaPeriod2 = (FakeMediaPeriodWithSelectTracksPosition) mergingMediaPeriod.getChildPeriod(1); assertThat(childMediaPeriod2.selectTracksPositionUs).isEqualTo(3000L); assertThat(streams[1].readData(formatHolder, inputBuffer, /* formatRequired= */ false)) .isEqualTo(C.RESULT_BUFFER_READ); - assertThat(inputBuffer.timeUs).isEqualTo(0L); + assertThat(inputBuffer.timeUs).isEqualTo(456_000 - 3000); } private MergingMediaPeriod prepareMergingPeriod(MergingPeriodDefinition... definitions) @@ -137,10 +153,11 @@ public final class MergingMediaPeriodTest { MediaPeriod[] mediaPeriods = new MediaPeriod[definitions.length]; long[] timeOffsetsUs = new long[definitions.length]; for (int i = 0; i < definitions.length; i++) { - timeOffsetsUs[i] = definitions[i].timeOffsetUs; - TrackGroup[] trackGroups = new TrackGroup[definitions[i].formats.length]; - for (int j = 0; j < definitions[i].formats.length; j++) { - trackGroups[j] = new TrackGroup(definitions[i].formats[j]); + MergingPeriodDefinition definition = definitions[i]; + timeOffsetsUs[i] = definition.timeOffsetUs; + TrackGroup[] trackGroups = new TrackGroup[definition.formats.length]; + for (int j = 0; j < definition.formats.length; j++) { + trackGroups[j] = new TrackGroup(definition.formats[j]); } mediaPeriods[i] = new FakeMediaPeriodWithSelectTracksPosition( @@ -149,7 +166,10 @@ public final class MergingMediaPeriodTest { .withParameters( /* windowIndex= */ i, new MediaPeriodId(/* periodUid= */ i), - /* mediaTimeOffsetMs= */ 0)); + /* mediaTimeOffsetMs= */ 0), + /* trackDataFactory= */ (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of( + oneByteSample(definition.singleSampleTimeUs), END_OF_STREAM_ITEM)); } MergingMediaPeriod mergingMediaPeriod = new MergingMediaPeriod( @@ -179,8 +199,15 @@ public final class MergingMediaPeriodTest { public long selectTracksPositionUs; public FakeMediaPeriodWithSelectTracksPosition( - TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher) { - super(trackGroupArray, eventDispatcher); + TrackGroupArray trackGroupArray, + EventDispatcher eventDispatcher, + TrackDataFactory trackDataFactory) { + super( + trackGroupArray, + trackDataFactory, + eventDispatcher, + DrmSessionManager.DUMMY, + /* deferOnPrepared= */ false); selectTracksPositionUs = C.TIME_UNSET; } @@ -199,11 +226,13 @@ public final class MergingMediaPeriodTest { private static final class MergingPeriodDefinition { - public long timeOffsetUs; - public Format[] formats; + public final long timeOffsetUs; + public final long singleSampleTimeUs; + public final Format[] formats; - public MergingPeriodDefinition(long timeOffsetUs, Format... formats) { + public MergingPeriodDefinition(long timeOffsetUs, long singleSampleTimeUs, Format... formats) { this.timeOffsetUs = timeOffsetUs; + this.singleSampleTimeUs = singleSampleTimeUs; this.formats = formats; } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java index 9fa06eed57..3d569aba6d 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.video; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -39,6 +40,7 @@ import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import java.util.concurrent.Phaser; import org.junit.After; import org.junit.Before; @@ -183,12 +185,10 @@ public final class DecoderVideoRendererTest { public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); renderer.enable( RendererConfiguration.DEFAULT, @@ -212,12 +212,10 @@ public final class DecoderVideoRendererTest { throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); renderer.enable( RendererConfiguration.DEFAULT, @@ -240,12 +238,10 @@ public final class DecoderVideoRendererTest { public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); renderer.enable( RendererConfiguration.DEFAULT, @@ -271,22 +267,18 @@ public final class DecoderVideoRendererTest { public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); FakeSampleStream fakeSampleStream2 = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); renderer.enable( RendererConfiguration.DEFAULT, new Format[] {H264_FORMAT}, @@ -317,22 +309,18 @@ public final class DecoderVideoRendererTest { public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); FakeSampleStream fakeSampleStream2 = new FakeSampleStream( - /* format= */ H264_FORMAT, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); renderer.enable( RendererConfiguration.DEFAULT, new Format[] {H264_FORMAT}, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java index 85b7604e42..840b87e227 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.video; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.format; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -45,6 +47,7 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryExcep import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import java.util.Collections; import java.util.List; import org.junit.Before; @@ -126,15 +129,14 @@ public class MediaCodecVideoRendererTest { public void render_dropsLateBuffer() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50_000, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), // First buffer. - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), // Late buffer. - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), // Last buffer. - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), // First buffer. + oneByteSample(/* timeUs= */ 50_000), // Late buffer. + oneByteSample(/* timeUs= */ 100_000), // Last buffer. + FakeSampleStreamItem.END_OF_STREAM_ITEM)); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, new Format[] {VIDEO_H264}, @@ -163,13 +165,11 @@ public class MediaCodecVideoRendererTest { RendererConfiguration.DEFAULT, new Format[] {VIDEO_H264}, new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 0, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)), /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, @@ -201,12 +201,10 @@ public class MediaCodecVideoRendererTest { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ pAsp1, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 5000, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ pAsp1, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, @@ -220,16 +218,12 @@ public class MediaCodecVideoRendererTest { mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); mediaCodecVideoRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); - fakeSampleStream.addFakeSampleStreamItem(new FakeSampleStreamItem(pAsp2)); - fakeSampleStream.addFakeSampleStreamItem( - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); - fakeSampleStream.addFakeSampleStreamItem( - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); - fakeSampleStream.addFakeSampleStreamItem(new FakeSampleStreamItem(pAsp3)); - fakeSampleStream.addFakeSampleStreamItem( - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); - fakeSampleStream.addFakeSampleStreamItem( - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem(format(pAsp2)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 5_000)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 10_000)); + fakeSampleStream.addFakeSampleStreamItem(format(pAsp3)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 15_000)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 20_000)); fakeSampleStream.addFakeSampleStreamItem(FakeSampleStreamItem.END_OF_STREAM_ITEM); mediaCodecVideoRenderer.setCurrentStreamFinal(); @@ -251,12 +245,10 @@ public class MediaCodecVideoRendererTest { throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, new Format[] {VIDEO_H264}, @@ -270,8 +262,7 @@ public class MediaCodecVideoRendererTest { mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); mediaCodecVideoRenderer.resetPosition(0); mediaCodecVideoRenderer.setCurrentStreamFinal(); - fakeSampleStream.addFakeSampleStreamItem( - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 0)); fakeSampleStream.addFakeSampleStreamItem(FakeSampleStreamItem.END_OF_STREAM_ITEM); int positionUs = 10; do { @@ -286,12 +277,10 @@ public class MediaCodecVideoRendererTest { public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, @@ -313,12 +302,10 @@ public class MediaCodecVideoRendererTest { throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, @@ -339,12 +326,10 @@ public class MediaCodecVideoRendererTest { public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, @@ -366,22 +351,20 @@ public class MediaCodecVideoRendererTest { public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); FakeSampleStream fakeSampleStream2 = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, new Format[] {VIDEO_H264}, @@ -410,22 +393,20 @@ public class MediaCodecVideoRendererTest { public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); FakeSampleStream fakeSampleStream2 = new FakeSampleStream( - /* format= */ VIDEO_H264, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, new Format[] {VIDEO_H264}, @@ -458,26 +439,23 @@ public class MediaCodecVideoRendererTest { public void onVideoFrameProcessingOffset_isCalledAfterOutputFormatChanges() throws ExoPlaybackException { Format mp4Uhd = VIDEO_H264.buildUpon().setWidth(3840).setHeight(2160).build(); - byte[] sampleData = new byte[0]; FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ mp4Uhd, DrmSessionManager.DUMMY, /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(mp4Uhd), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(VIDEO_H264), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(mp4Uhd), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - new FakeSampleStreamItem(VIDEO_H264), - new FakeSampleStreamItem(sampleData, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* initialFormat= */ mp4Uhd, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), + format(VIDEO_H264), + oneByteSample(/* timeUs= */ 50), + oneByteSample(/* timeUs= */ 100), + format(mp4Uhd), + oneByteSample(/* timeUs= */ 150), + oneByteSample(/* timeUs= */ 200), + oneByteSample(/* timeUs= */ 250), + format(VIDEO_H264), + oneByteSample(/* timeUs= */ 300), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java index 67b08cbd58..e4e7002d8c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java @@ -58,7 +58,14 @@ public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod FakeChunkSource.Factory chunkSourceFactory, long durationUs, @Nullable TransferListener transferListener) { - super(trackGroupArray, eventDispatcher); + super( + trackGroupArray, + /* trackDataFactory= */ (unusedFormat, unusedMediaPeriodId) -> { + throw new RuntimeException("unused track data"); + }, + eventDispatcher, + DrmSessionManager.DUMMY, + /* deferOnPrepared= */ false); this.allocator = allocator; this.chunkSourceFactory = chunkSourceFactory; this.transferListener = transferListener; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java index 451293746d..216f823f5b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java @@ -38,7 +38,13 @@ public class FakeAdaptiveMediaSource extends FakeMediaSource { Timeline timeline, TrackGroupArray trackGroupArray, FakeChunkSource.Factory chunkSourceFactory) { - super(timeline, DrmSessionManager.DUMMY, trackGroupArray); + super( + timeline, + DrmSessionManager.DUMMY, + /* trackDataFactory= */ (unusedFormat, unusedMediaPeriodId) -> { + throw new RuntimeException("Unused TrackDataFactory"); + }, + trackGroupArray); this.chunkSourceFactory = chunkSourceFactory; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index e83d924293..a69265fb3b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import android.net.Uri; @@ -22,17 +24,22 @@ import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -52,6 +59,7 @@ public class FakeMediaPeriod implements MediaPeriod { private final List sampleStreams; private final DrmSessionManager drmSessionManager; private final EventDispatcher eventDispatcher; + private final TrackDataFactory trackDataFactory; private final long fakePreparationLoadTaskId; @Nullable private Handler playerHandler; @@ -64,48 +72,71 @@ public class FakeMediaPeriod implements MediaPeriod { private long discontinuityPositionUs; /** - * Constructs a FakeMediaPeriod. + * Constructs a FakeMediaPeriod with a single sample for each track in {@code trackGroupArray}. * * @param trackGroupArray The track group array. + * @param singleSampleTimeUs The timestamp to use for the single sample in each track, in + * microseconds. * @param eventDispatcher A dispatcher for media source events. */ - public FakeMediaPeriod(TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher) { - this(trackGroupArray, DrmSessionManager.DUMMY, eventDispatcher, /* deferOnPrepared */ false); + public FakeMediaPeriod( + TrackGroupArray trackGroupArray, long singleSampleTimeUs, EventDispatcher eventDispatcher) { + this( + trackGroupArray, + TrackDataFactory.singleSampleWithTimeUs(singleSampleTimeUs), + eventDispatcher, + DrmSessionManager.DUMMY, + /* deferOnPrepared= */ false); } /** - * Constructs a FakeMediaPeriod. + * Constructs a FakeMediaPeriod with a single sample for each track in {@code trackGroupArray}. * * @param trackGroupArray The track group array. + * @param singleSampleTimeUs The timestamp to use for the single sample in each track, in + * microseconds. + * @param eventDispatcher A dispatcher for media source events. * @param drmSessionManager The {@link DrmSessionManager} used for DRM interactions. - * @param eventDispatcher A dispatcher for media source events. + * @param deferOnPrepared Whether {@link Callback#onPrepared(MediaPeriod)} should be called only + * after {@link #setPreparationComplete()} has been called. If {@code false} */ public FakeMediaPeriod( TrackGroupArray trackGroupArray, + long singleSampleTimeUs, + EventDispatcher eventDispatcher, DrmSessionManager drmSessionManager, - EventDispatcher eventDispatcher) { - this(trackGroupArray, drmSessionManager, eventDispatcher, /* deferOnPrepared */ false); + boolean deferOnPrepared) { + this( + trackGroupArray, + TrackDataFactory.singleSampleWithTimeUs(singleSampleTimeUs), + eventDispatcher, + drmSessionManager, + deferOnPrepared); } /** * Constructs a FakeMediaPeriod. * * @param trackGroupArray The track group array. - * @param drmSessionManager The DrmSessionManager used for DRM interactions. + * @param trackDataFactory A source for the underlying sample data for each track in {@code + * trackGroupArray}. * @param eventDispatcher A dispatcher for media source events. - * @param deferOnPrepared Whether {@link MediaPeriod.Callback#onPrepared(MediaPeriod)} should be - * called only after {@link #setPreparationComplete()} has been called. If {@code false} - * preparation completes immediately. + * @param drmSessionManager The DrmSessionManager used for DRM interactions. + * @param deferOnPrepared Whether {@link Callback#onPrepared(MediaPeriod)} should be called only + * after {@link #setPreparationComplete()} has been called. If {@code false} preparation + * completes immediately. */ public FakeMediaPeriod( TrackGroupArray trackGroupArray, - DrmSessionManager drmSessionManager, + TrackDataFactory trackDataFactory, EventDispatcher eventDispatcher, + DrmSessionManager drmSessionManager, boolean deferOnPrepared) { this.trackGroupArray = trackGroupArray; this.drmSessionManager = drmSessionManager; this.eventDispatcher = eventDispatcher; this.deferOnPrepared = deferOnPrepared; + this.trackDataFactory = trackDataFactory; discontinuityPositionUs = C.TIME_UNSET; sampleStreams = new ArrayList<>(); fakePreparationLoadTaskId = LoadEventInfo.getNewId(); @@ -282,13 +313,16 @@ public class FakeMediaPeriod implements MediaPeriod { TrackSelection selection, DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher) { - return new FakeSampleStream( - selection.getSelectedFormat(), - drmSessionManager, - eventDispatcher, - positionUs, - /* timeUsIncrement= */ 0, - FakeSampleStream.SINGLE_SAMPLE_THEN_END_OF_STREAM); + FakeSampleStream sampleStream = + new FakeSampleStream( + drmSessionManager, + eventDispatcher, + selection.getSelectedFormat(), + trackDataFactory.create( + selection.getSelectedFormat(), + Assertions.checkNotNull(eventDispatcher.mediaPeriodId))); + sampleStream.seekTo(positionUs); + return sampleStream; } /** @@ -300,8 +334,7 @@ public class FakeMediaPeriod implements MediaPeriod { */ protected void seekSampleStream(SampleStream sampleStream, long positionUs) { // Queue a single sample from the seek position again. - ((FakeSampleStream) sampleStream) - .resetSampleStreamItems(positionUs, FakeSampleStream.SINGLE_SAMPLE_THEN_END_OF_STREAM); + ((FakeSampleStream) sampleStream).seekTo(positionUs); } /** @@ -334,4 +367,27 @@ public class FakeMediaPeriod implements MediaPeriod { /* mediaStartTimeUs= */ 0, /* mediaEndTimeUs = */ C.TIME_UNSET); } + + /** A factory to create the test data for a particular track. */ + public interface TrackDataFactory { + + /** + * Returns the list of {@link FakeSampleStreamItem}s that will be passed to {@link + * FakeSampleStream#FakeSampleStream(DrmSessionManager, EventDispatcher, Format, List)}. + * + * @param format The format of the track to provide data for. + * @param mediaPeriodId The {@link MediaPeriodId} to provide data for. + * @return The track data in the form of {@link FakeSampleStreamItem}s. + */ + List create(Format format, MediaPeriodId mediaPeriodId); + + /** + * Returns a factory that always provides a single sample with {@code time=sampleTimeUs} and + * then end-of-stream. + */ + static TrackDataFactory singleSampleWithTimeUs(long sampleTimeUs) { + return (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of(oneByteSample(sampleTimeUs), END_OF_STREAM_ITEM); + } + } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index d4d4e76054..741594686a 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -36,6 +36,7 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.testutil.FakeMediaPeriod.TrackDataFactory; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.TransferListener; @@ -46,6 +47,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Fake {@link MediaSource} that provides a given timeline. Creating the period will return a {@link @@ -78,6 +80,7 @@ public class FakeMediaSource extends BaseMediaSource { private static final int MANIFEST_LOAD_BYTES = 100; private final TrackGroupArray trackGroupArray; + @Nullable private final FakeMediaPeriod.TrackDataFactory trackDataFactory; private final ArrayList activeMediaPeriods; private final ArrayList createdMediaPeriods; private final DrmSessionManager drmSessionManager; @@ -107,18 +110,35 @@ public class FakeMediaSource extends BaseMediaSource { */ public FakeMediaSource( @Nullable Timeline timeline, DrmSessionManager drmSessionManager, Format... formats) { - this(timeline, drmSessionManager, buildTrackGroupArray(formats)); + this(timeline, drmSessionManager, /* trackDataFactory= */ null, buildTrackGroupArray(formats)); + } + + /** + * Creates a {@link FakeMediaSource}. This media source creates {@link FakeMediaPeriod}s with a + * {@link TrackGroupArray} using the given {@link Format}s. It passes {@code drmSessionManager} + * and {@code trackDataFactory} into the created periods. The provided {@link Timeline} may be + * null to prevent an immediate source info refresh message when preparing the media source. It + * can be manually set later using {@link #setNewSourceInfo(Timeline)}. + */ + public FakeMediaSource( + @Nullable Timeline timeline, + DrmSessionManager drmSessionManager, + @Nullable FakeMediaPeriod.TrackDataFactory trackDataFactory, + Format... formats) { + this(timeline, drmSessionManager, trackDataFactory, buildTrackGroupArray(formats)); } /** * Creates a {@link FakeMediaSource}. This media source creates {@link FakeMediaPeriod}s with the - * given {@link TrackGroupArray}. The provided {@link Timeline} may be null to prevent an + * provided {@link TrackGroupArray}, {@link DrmSessionManager} and {@link + * FakeMediaPeriod.TrackDataFactory}. The provided {@link Timeline} may be null to prevent an * immediate source info refresh message when preparing the media source. It can be manually set * later using {@link #setNewSourceInfo(Timeline)}. */ public FakeMediaSource( @Nullable Timeline timeline, DrmSessionManager drmSessionManager, + @Nullable FakeMediaPeriod.TrackDataFactory trackDataFactory, TrackGroupArray trackGroupArray) { if (timeline != null) { this.timeline = timeline; @@ -127,6 +147,7 @@ public class FakeMediaSource extends BaseMediaSource { this.activeMediaPeriods = new ArrayList<>(); this.createdMediaPeriods = new ArrayList<>(); this.drmSessionManager = drmSessionManager; + this.trackDataFactory = trackDataFactory; } @Nullable @@ -292,6 +313,7 @@ public class FakeMediaSource extends BaseMediaSource { * May be null if no listener is available. * @return A new {@link FakeMediaPeriod}. */ + @RequiresNonNull("this.timeline") protected FakeMediaPeriod createFakeMediaPeriod( MediaPeriodId id, TrackGroupArray trackGroupArray, @@ -299,8 +321,17 @@ public class FakeMediaSource extends BaseMediaSource { DrmSessionManager drmSessionManager, EventDispatcher eventDispatcher, @Nullable TransferListener transferListener) { + long positionInWindowUs = + timeline.getPeriodByUid(id.periodUid, new Period()).getPositionInWindowUs(); + long defaultFirstSampleTimeUs = positionInWindowUs >= 0 || id.isAd() ? 0 : -positionInWindowUs; return new FakeMediaPeriod( - trackGroupArray, drmSessionManager, eventDispatcher, /* deferOnPrepared= */ false); + trackGroupArray, + trackDataFactory != null + ? trackDataFactory + : TrackDataFactory.singleSampleWithTimeUs(defaultFirstSampleTimeUs), + eventDispatcher, + drmSessionManager, + /* deferOnPrepared= */ false); } private void finishSourcePreparation(boolean sendManifestLoadEvents) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java index 0d84bcb48c..420b9b83ae 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java @@ -15,9 +15,11 @@ */ package com.google.android.exoplayer2.testutil; + import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.BufferFlags; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; @@ -29,9 +31,11 @@ import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.Iterables; import java.io.IOException; -import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -40,11 +44,20 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public class FakeSampleStream implements SampleStream { + private static class SampleInfo { + private final byte[] data; + @C.BufferFlags private final int flags; + private final long timeUs; + + private SampleInfo(byte[] data, @C.BufferFlags int flags, long timeUs) { + this.data = Arrays.copyOf(data, data.length); + this.flags = flags; + this.timeUs = timeUs; + } + } + /** Item to customize a return value of {@link FakeSampleStream#readData}. */ public static final class FakeSampleStreamItem { - @Nullable Format format; - @Nullable byte[] sampleData; - int flags; /** * Item that designates the end of stream has been reached. @@ -52,118 +65,126 @@ public class FakeSampleStream implements SampleStream { *

        When this item is read, readData will repeatedly return end of stream. */ public static final FakeSampleStreamItem END_OF_STREAM_ITEM = - new FakeSampleStreamItem(new byte[] {}, C.BUFFER_FLAG_END_OF_STREAM); + sample( + /* timeUs= */ Long.MAX_VALUE, + C.BUFFER_FLAG_END_OF_STREAM, + /* sampleData= */ new byte[] {}); + + /** Creates an item representing the provided format. */ + public static FakeSampleStreamItem format(Format format) { + return new FakeSampleStreamItem(format, /* sampleInfo= */ null); + } /** - * Item that, when {@link #readData(FormatHolder, DecoderInputBuffer, boolean)} is called, will - * return {@link C#RESULT_FORMAT_READ} with the new format. + * Creates an item representing a sample with the provided timestamp. * - * @param format The format to be returned. + *

        The sample will contain a single byte of data. + * + * @param timeUs The timestamp of the sample. */ - public FakeSampleStreamItem(Format format) { + public static FakeSampleStreamItem oneByteSample(long timeUs) { + return oneByteSample(timeUs, /* flags= */ 0); + } + + /** + * Creates an item representing a sample with the provided timestamp and flags. + * + *

        The sample will contain a single byte of data. + * + * @param timeUs The timestamp of the sample. + * @param flags The buffer flags that will be set when reading this sample through {@link + * FakeSampleStream#readData(FormatHolder, DecoderInputBuffer, boolean)}. + */ + public static FakeSampleStreamItem oneByteSample(long timeUs, @BufferFlags int flags) { + return sample(timeUs, flags, new byte[] {0}); + } + + /** + * Creates an item representing a sample with the provided timestamp, flags and data. + * + * @param timeUs The timestamp of the sample. + * @param flags The buffer flags that will be set when reading this sample through {@link + * FakeSampleStream#readData(FormatHolder, DecoderInputBuffer, boolean)}. + * @param sampleData The sample data. + */ + public static FakeSampleStreamItem sample( + long timeUs, @BufferFlags int flags, byte[] sampleData) { + return new FakeSampleStreamItem( + /* format= */ null, new SampleInfo(sampleData.clone(), flags, timeUs)); + } + + @Nullable private final Format format; + @Nullable private final SampleInfo sampleInfo; + + /** + * Creates an instance. Exactly one of {@code format} or {@code sampleInfo} must be non-null. + */ + private FakeSampleStreamItem(@Nullable Format format, @Nullable SampleInfo sampleInfo) { + Assertions.checkArgument((format == null) != (sampleInfo == null)); this.format = format; - } - - /** - * Item that, when {@link #readData(FormatHolder, DecoderInputBuffer, boolean)} is called, will - * return {@link C#RESULT_BUFFER_READ} with the sample data. - * - * @param sampleData The sample data to be read. - */ - public FakeSampleStreamItem(byte[] sampleData) { - this.sampleData = sampleData.clone(); - } - - /** - * Item that, when {@link #readData(FormatHolder, DecoderInputBuffer, boolean)} is called, will - * return {@link C#RESULT_BUFFER_READ} with the sample data. - * - * @param sampleData The sample data to be read. - * @param flags The buffer flags to be set. - */ - public FakeSampleStreamItem(byte[] sampleData, int flags) { - this.sampleData = sampleData.clone(); - this.flags = flags; + this.sampleInfo = sampleInfo; } } - /** Constant array for use when a single sample is to be output, followed by the end of stream. */ - public static final FakeSampleStreamItem[] SINGLE_SAMPLE_THEN_END_OF_STREAM = - new FakeSampleStreamItem[] { - new FakeSampleStreamItem(new byte[] {0}), FakeSampleStreamItem.END_OF_STREAM_ITEM - }; - private final Format initialFormat; - private final ArrayDeque fakeSampleStreamItems; - private final int timeUsIncrement; + private final List fakeSampleStreamItems; private final DrmSessionManager drmSessionManager; @Nullable private final EventDispatcher eventDispatcher; + private int sampleItemIndex; private @MonotonicNonNull Format downstreamFormat; - private long timeUs; private boolean readEOSBuffer; @Nullable private DrmSession currentDrmSession; /** - * Creates fake sample stream which outputs the given {@link Format}, optionally one sample with - * zero bytes, then end of stream. + * Creates a fake sample stream which outputs the given {@link Format} followed by the provided + * {@link FakeSampleStreamItem items}. * - * @param format The {@link Format} to output. - * @param eventDispatcher An {@link EventDispatcher} to notify of read events. - * @param shouldOutputSample Whether the sample stream should output a sample. - */ - public FakeSampleStream( - Format format, @Nullable EventDispatcher eventDispatcher, boolean shouldOutputSample) { - this( - format, - DrmSessionManager.DUMMY, - eventDispatcher, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 0, - shouldOutputSample - ? SINGLE_SAMPLE_THEN_END_OF_STREAM - : new FakeSampleStreamItem[] {FakeSampleStreamItem.END_OF_STREAM_ITEM}); - } - - /** - * Creates a fake sample stream which outputs the given {@link Format}, any amount of {@link - * FakeSampleStreamItem items}, then end of stream. - * - * @param format The {@link Format} to output. * @param drmSessionManager A {@link DrmSessionManager} for DRM interactions. * @param eventDispatcher An {@link EventDispatcher} to notify of read events. - * @param firstSampleTimeUs The time at which samples will start being output, in microseconds. - * @param timeUsIncrement The time each sample should increase by, in microseconds. + * @param initialFormat The first {@link Format} to output. * @param fakeSampleStreamItems The {@link FakeSampleStreamItem items} to customize the return - * values of {@link #readData(FormatHolder, DecoderInputBuffer, boolean)}. Note that once an - * EOS buffer has been read, that will return every time readData is called. + * values of {@link #readData(FormatHolder, DecoderInputBuffer, boolean)}. This is assumed to + * be in ascending order of sampleTime. Note that once an EOS buffer has been read, that will + * return every time readData is called. This should usually end with {@link + * FakeSampleStreamItem#END_OF_STREAM_ITEM}. */ public FakeSampleStream( - Format format, DrmSessionManager drmSessionManager, @Nullable EventDispatcher eventDispatcher, - long firstSampleTimeUs, - int timeUsIncrement, - FakeSampleStreamItem... fakeSampleStreamItems) { - this.initialFormat = format; + Format initialFormat, + List fakeSampleStreamItems) { this.drmSessionManager = drmSessionManager; this.eventDispatcher = eventDispatcher; - this.fakeSampleStreamItems = new ArrayDeque<>(Arrays.asList(fakeSampleStreamItems)); - this.timeUs = firstSampleTimeUs; - this.timeUsIncrement = timeUsIncrement; + this.initialFormat = initialFormat; + this.fakeSampleStreamItems = new ArrayList<>(fakeSampleStreamItems); } /** - * Clears and assigns new samples provided by this sample stream. + * Seeks inside this sample stream. * - * @param timeUs The time at which samples will start being output, in microseconds. - * @param fakeSampleStreamItems The {@link FakeSampleStreamItem items} to provide. + *

        Seeks to just before the first sample with {@code sampleTime >= timeUs}, or to the end of + * the stream otherwise. */ - public void resetSampleStreamItems(long timeUs, FakeSampleStreamItem... fakeSampleStreamItems) { - this.fakeSampleStreamItems.clear(); - this.fakeSampleStreamItems.addAll(Arrays.asList(fakeSampleStreamItems)); - this.timeUs = timeUs; - readEOSBuffer = false; + public void seekTo(long timeUs) { + Format applicableFormat = initialFormat; + for (int i = 0; i < fakeSampleStreamItems.size(); i++) { + @Nullable SampleInfo sampleInfo = fakeSampleStreamItems.get(i).sampleInfo; + if (sampleInfo == null) { + applicableFormat = Assertions.checkNotNull(fakeSampleStreamItems.get(i).format); + continue; + } + if (sampleInfo.timeUs >= timeUs) { + sampleItemIndex = i; + readEOSBuffer = false; + if (downstreamFormat != null && !applicableFormat.equals(downstreamFormat)) { + notifyEventDispatcher(applicableFormat); + } + return; + } + } + sampleItemIndex = fakeSampleStreamItems.size(); + readEOSBuffer = true; } /** @@ -177,10 +198,10 @@ public class FakeSampleStream implements SampleStream { @Override public boolean isReady() { - if (fakeSampleStreamItems.isEmpty()) { + if (sampleItemIndex == fakeSampleStreamItems.size()) { return readEOSBuffer || downstreamFormat == null; } - if (fakeSampleStreamItems.peek().format != null) { + if (fakeSampleStreamItems.get(sampleItemIndex).format != null) { // A format can be read. return true; } @@ -200,29 +221,28 @@ public class FakeSampleStream implements SampleStream { return C.RESULT_BUFFER_READ; } - if (!fakeSampleStreamItems.isEmpty()) { - FakeSampleStreamItem fakeSampleStreamItem = fakeSampleStreamItems.remove(); + if (sampleItemIndex < fakeSampleStreamItems.size()) { + FakeSampleStreamItem fakeSampleStreamItem = fakeSampleStreamItems.get(sampleItemIndex); + sampleItemIndex++; if (fakeSampleStreamItem.format != null) { onFormatResult(fakeSampleStreamItem.format, formatHolder); return C.RESULT_FORMAT_READ; } else { - byte[] sampleData = Assertions.checkNotNull(fakeSampleStreamItem.sampleData); - if (fakeSampleStreamItem.flags != 0) { - buffer.setFlags(fakeSampleStreamItem.flags); + SampleInfo sampleInfo = Assertions.checkNotNull(fakeSampleStreamItem.sampleInfo); + if (sampleInfo.flags != 0) { + buffer.setFlags(sampleInfo.flags); if (buffer.isEndOfStream()) { readEOSBuffer = true; return C.RESULT_BUFFER_READ; } } if (!mayReadSample()) { - // Put the item back so we can consume it next time. - fakeSampleStreamItems.addFirst(fakeSampleStreamItem); + sampleItemIndex--; return C.RESULT_NOTHING_READ; } - buffer.timeUs = timeUs; - timeUs += timeUsIncrement; - buffer.ensureSpaceForWrite(sampleData.length); - buffer.data.put(sampleData); + buffer.timeUs = sampleInfo.timeUs; + buffer.ensureSpaceForWrite(sampleInfo.data.length); + buffer.data.put(sampleInfo.data); return C.RESULT_BUFFER_READ; } } @@ -237,7 +257,7 @@ public class FakeSampleStream implements SampleStream { downstreamFormat = newFormat; @Nullable DrmInitData newDrmInitData = newFormat.drmInitData; outputFormatHolder.drmSession = currentDrmSession; - notifyEventDispatcher(outputFormatHolder); + notifyEventDispatcher(newFormat); if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { // Nothing to do. return; @@ -260,11 +280,16 @@ public class FakeSampleStream implements SampleStream { private boolean mayReadSample() { @Nullable DrmSession drmSession = this.currentDrmSession; + @Nullable + FakeSampleStreamItem nextSample = + Iterables.get(fakeSampleStreamItems, sampleItemIndex, /* defaultValue= */ null); + boolean nextSampleIsClear = + nextSample != null + && nextSample.sampleInfo != null + && (nextSample.sampleInfo.flags & C.BUFFER_FLAG_ENCRYPTED) == 0; return drmSession == null || drmSession.getState() == DrmSession.STATE_OPENED_WITH_KEYS - || (!fakeSampleStreamItems.isEmpty() - && (fakeSampleStreamItems.peek().flags & C.BUFFER_FLAG_ENCRYPTED) == 0 - && drmSession.playClearSamplesWithoutKeys()); + || (nextSampleIsClear && drmSession.playClearSamplesWithoutKeys()); } @Override @@ -276,6 +301,7 @@ public class FakeSampleStream implements SampleStream { @Override public int skipData(long positionUs) { + // TODO: Implement this. return 0; } @@ -287,14 +313,22 @@ public class FakeSampleStream implements SampleStream { } } - private void notifyEventDispatcher(FormatHolder formatHolder) { + private void notifyEventDispatcher(Format format) { if (eventDispatcher != null) { + @Nullable SampleInfo sampleInfo = null; + for (int i = sampleItemIndex; i < fakeSampleStreamItems.size(); i++) { + sampleInfo = fakeSampleStreamItems.get(i).sampleInfo; + if (sampleInfo != null) { + break; + } + } + long nextSampleTimeUs = sampleInfo != null ? sampleInfo.timeUs : C.TIME_END_OF_SOURCE; eventDispatcher.downstreamFormatChanged( C.TRACK_TYPE_UNKNOWN, - formatHolder.format, + format, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, - /* mediaTimeUs= */ timeUs); + /* mediaTimeUs= */ nextSampleTimeUs); } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 3fc9143fa7..303a727061 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -37,8 +37,7 @@ public final class FakeTimeline extends Timeline { public static final long DEFAULT_WINDOW_DURATION_US = 10 * C.MICROS_PER_SECOND; /** Default offset of a window in its first period in microseconds. */ - public static final long DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US = - 10_000 * C.MICROS_PER_SECOND; + public static final long DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US = 123 * C.MICROS_PER_SECOND; public final int periodCount; public final Object id; @@ -187,7 +186,7 @@ public final class FakeTimeline extends Timeline { public static final MediaItem FAKE_MEDIA_ITEM = new MediaItem.Builder().setMediaId("FakeTimeline").setUri(Uri.EMPTY).build(); - private static final long AD_DURATION_US = 10 * C.MICROS_PER_SECOND; + private static final long AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private final TimelineWindowDefinition[] windowDefinitions; private final Object[] manifests; From 4fc45b92c090c649d30a8bb3830de86c7c38b280 Mon Sep 17 00:00:00 2001 From: insun Date: Fri, 3 Jul 2020 01:47:22 +0100 Subject: [PATCH 0589/1052] Increase char limits for some string translations And also replaced "Click" with "Tap" for CC button descriptions. PiperOrigin-RevId: 319455553 --- library/ui/src/main/res/values/strings.xml | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/library/ui/src/main/res/values/strings.xml b/library/ui/src/main/res/values/strings.xml index 764d9d6968..210f35f48c 100644 --- a/library/ui/src/main/res/values/strings.xml +++ b/library/ui/src/main/res/values/strings.xml @@ -111,42 +111,42 @@ 00:00:00 + [CHAR_LIMIT=55] --> Back to previous button list + [CHAR_LIMIT=55] --> See more buttons + [CHAR_LIMIT=55] --> Playback progress + [CHAR_LIMIT=30] --> Settings - Subtitle is on. Click to hide it. + [CHAR_LIMIT=55] --> + Tap to hide subtitles - Subtitle is off. Click to show it. + [CHAR_LIMIT=55] --> + Tap to show subtitles + [CHAR_LIMIT=55] --> - Rewind by %d seconds + Rewind %d seconds + [CHAR_LIMIT=55] --> - Go forward by %d seconds + Fast forward %d seconds - Full screen enter + [CHAR_LIMIT=40] --> + Enter fullscreen - Full screen exit + [CHAR_LIMIT=40] --> + Exit fullscreen From ccb337f2e14d8e1c0ebeb89e131dcce3207e2948 Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 3 Jul 2020 11:43:28 +0100 Subject: [PATCH 0590/1052] MP4: set TrackSampleTable to 0 when there are no samples Because the stbl atom is mandatory, there is no reason for having a special C.TIME_UNSET value instead of 0. PiperOrigin-RevId: 319496999 --- .../google/android/exoplayer2/extractor/mp4/AtomParsers.java | 2 +- .../android/exoplayer2/extractor/mp4/TrackSampleTable.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 13ffcc2ff1..a20776595e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -315,7 +315,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /* maximumSize= */ 0, /* timestampsUs= */ new long[0], /* flags= */ new int[0], - /* durationUs= */ C.TIME_UNSET); + /* durationUs= */ 0); } // Entries are byte offsets of chunks. diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java index 59ea386335..ca500b2931 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java @@ -38,10 +38,7 @@ import com.google.android.exoplayer2.util.Util; public final long[] timestampsUs; /** Sample flags. */ public final int[] flags; - /** - * The duration of the track sample table in microseconds, or {@link C#TIME_UNSET} if the sample - * table is empty. - */ + /** The duration of the track sample table in microseconds. */ public final long durationUs; public TrackSampleTable( From 968a0baa8d04156dfcb4909e425522fb8cc343f3 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 3 Jul 2020 11:45:57 +0100 Subject: [PATCH 0591/1052] Fix handling of intents with no license server URI PiperOrigin-RevId: 319497156 --- .../java/com/google/android/exoplayer2/demo/IntentUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java index 96faac8d92..474ec25db6 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -274,7 +274,7 @@ public class IntentUtil { intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.uuid.toString()); intent.putExtra( DRM_LICENSE_URL_EXTRA + extrasKeySuffix, - checkNotNull(drmConfiguration.licenseUri).toString()); + drmConfiguration.licenseUri != null ? drmConfiguration.licenseUri.toString() : null); intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmConfiguration.multiSession); intent.putExtra( DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, From 113a2df77549d2b96f467156b6cfcb176ff288a1 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 3 Jul 2020 12:59:32 +0100 Subject: [PATCH 0592/1052] Add some missing nullness assertions. They are all for Context.getSystemService that is allowed to return null. In most cases where we need to service, we make an assertion that it is available. PiperOrigin-RevId: 319503557 --- .../main/java/com/google/android/exoplayer2/C.java | 6 ++++-- .../com/google/android/exoplayer2/util/Util.java | 9 +++++---- .../google/android/exoplayer2/AudioFocusManager.java | 9 ++++++--- .../exoplayer2/scheduler/PlatformScheduler.java | 10 ++++++---- .../exoplayer2/scheduler/RequirementsWatcher.java | 12 ++++++------ .../android/exoplayer2/util/NotificationUtil.java | 7 +++++-- 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index 64123b730e..a4996879e4 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -22,6 +22,7 @@ import android.media.AudioManager; import android.media.MediaCodec; import android.media.MediaFormat; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; @@ -1110,8 +1111,9 @@ public final class C { */ @RequiresApi(21) public static int generateAudioSessionIdV21(Context context) { - return ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)) - .generateAudioSessionId(); + @Nullable + AudioManager audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); + return audioManager == null ? AudioManager.ERROR : audioManager.generateAudioSessionId(); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 0afeaffdaf..fee10c349a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.util; import static android.content.Context.UI_MODE_SERVICE; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import android.Manifest.permission; import android.annotation.SuppressLint; @@ -1829,8 +1830,7 @@ public final class Util { Matcher matcher = ESCAPED_CHARACTER_PATTERN.matcher(fileName); int startOfNotEscaped = 0; while (percentCharacterCount > 0 && matcher.find()) { - char unescapedCharacter = - (char) Integer.parseInt(Assertions.checkNotNull(matcher.group(1)), 16); + char unescapedCharacter = (char) Integer.parseInt(checkNotNull(matcher.group(1)), 16); builder.append(fileName, startOfNotEscaped, matcher.start()).append(unescapedCharacter); startOfNotEscaped = matcher.end(); percentCharacterCount--; @@ -2086,7 +2086,8 @@ public final class Util { * @return The size of the current mode, in pixels. */ public static Point getCurrentDisplayModeSize(Context context) { - WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + WindowManager windowManager = + checkNotNull((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)); return getCurrentDisplayModeSize(context, windowManager.getDefaultDisplay()); } @@ -2327,7 +2328,7 @@ public final class Util { private static boolean isTrafficRestricted(Uri uri) { return "http".equals(uri.getScheme()) && !NetworkSecurityPolicy.getInstance() - .isCleartextTrafficPermitted(Assertions.checkNotNull(uri.getHost())); + .isCleartextTrafficPermitted(checkNotNull(uri.getHost())); } private static String maybeReplaceGrandfatheredLanguageTags(String languageTag) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java b/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java index 5aeca440ff..b56e8838c5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.content.Context; import android.media.AudioFocusRequest; import android.media.AudioManager; @@ -116,7 +118,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public AudioFocusManager(Context context, Handler eventHandler, PlayerControl playerControl) { this.audioManager = - (AudioManager) context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + checkNotNull( + (AudioManager) context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE)); this.playerControl = playerControl; this.focusListener = new AudioFocusListener(eventHandler); this.audioFocusState = AUDIO_FOCUS_STATE_NO_FOCUS; @@ -212,7 +215,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private int requestAudioFocusDefault() { return audioManager.requestAudioFocus( focusListener, - Util.getStreamTypeForAudioUsage(Assertions.checkNotNull(audioAttributes).usage), + Util.getStreamTypeForAudioUsage(checkNotNull(audioAttributes).usage), focusGain); } @@ -227,7 +230,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; boolean willPauseWhenDucked = willPauseWhenDucked(); audioFocusRequest = builder - .setAudioAttributes(Assertions.checkNotNull(audioAttributes).getAudioAttributesV21()) + .setAudioAttributes(checkNotNull(audioAttributes).getAudioAttributesV21()) .setWillPauseWhenDucked(willPauseWhenDucked) .setOnAudioFocusChangeListener(focusListener) .build(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java index 11036fc77c..357fdab957 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.scheduler; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.app.job.JobInfo; import android.app.job.JobParameters; import android.app.job.JobScheduler; @@ -25,7 +27,6 @@ import android.content.Intent; import android.os.PersistableBundle; import androidx.annotation.RequiresApi; import androidx.annotation.RequiresPermission; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; @@ -72,7 +73,8 @@ public final class PlatformScheduler implements Scheduler { context = context.getApplicationContext(); this.jobId = jobId; jobServiceComponentName = new ComponentName(context, PlatformSchedulerService.class); - jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + jobScheduler = + checkNotNull((JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE)); } @Override @@ -140,8 +142,8 @@ public final class PlatformScheduler implements Scheduler { Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS)); int notMetRequirements = requirements.getNotMetRequirements(this); if (notMetRequirements == 0) { - String serviceAction = Assertions.checkNotNull(extras.getString(KEY_SERVICE_ACTION)); - String servicePackage = Assertions.checkNotNull(extras.getString(KEY_SERVICE_PACKAGE)); + String serviceAction = checkNotNull(extras.getString(KEY_SERVICE_ACTION)); + String servicePackage = checkNotNull(extras.getString(KEY_SERVICE_PACKAGE)); Intent intent = new Intent(serviceAction).setPackage(servicePackage); Util.startForegroundService(this, intent); } else { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index f0a9ae3efc..6293cbf36d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.scheduler; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -27,7 +29,6 @@ import android.os.Looper; import android.os.PowerManager; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; /** @@ -115,7 +116,7 @@ public final class RequirementsWatcher { /** Stops watching for changes. */ public void stop() { - context.unregisterReceiver(Assertions.checkNotNull(receiver)); + context.unregisterReceiver(checkNotNull(receiver)); receiver = null; if (Util.SDK_INT >= 24 && networkCallback != null) { unregisterNetworkCallbackV24(); @@ -130,8 +131,7 @@ public final class RequirementsWatcher { @RequiresApi(24) private void registerNetworkCallbackV24() { ConnectivityManager connectivityManager = - Assertions.checkNotNull( - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + checkNotNull((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); networkCallback = new NetworkCallback(); connectivityManager.registerDefaultNetworkCallback(networkCallback); } @@ -139,8 +139,8 @@ public final class RequirementsWatcher { @RequiresApi(24) private void unregisterNetworkCallbackV24() { ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - connectivityManager.unregisterNetworkCallback(Assertions.checkNotNull(networkCallback)); + checkNotNull((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + connectivityManager.unregisterNetworkCallback(checkNotNull(networkCallback)); networkCallback = null; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java index 756494f9d0..6c2b337344 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationChannel; @@ -99,7 +101,8 @@ public final class NotificationUtil { @Importance int importance) { if (Util.SDK_INT >= 26) { NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + checkNotNull( + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); NotificationChannel channel = new NotificationChannel(id, context.getString(nameResourceId), importance); if (descriptionResourceId != 0) { @@ -122,7 +125,7 @@ public final class NotificationUtil { */ public static void setNotification(Context context, int id, @Nullable Notification notification) { NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + checkNotNull((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); if (notification != null) { notificationManager.notify(id, notification); } else { From 08478d116310588691ade2714af890648cc06919 Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 3 Jul 2020 13:00:28 +0100 Subject: [PATCH 0593/1052] Remove throws clause from Renderer.stop We don't need the renderer immediately after stopping, so the renderer should not throw a checked exception until it's used again. This is inline with the not throwing from disable(). Also, none of the known implementation throw an exception at the moment and all reasonable base classes omit the throws clause already. PiperOrigin-RevId: 319503643 --- RELEASENOTES.md | 1 + .../android/exoplayer2/BaseRenderer.java | 8 +-- .../android/exoplayer2/NoSampleRenderer.java | 8 +-- .../google/android/exoplayer2/Renderer.java | 8 +-- .../android/exoplayer2/ExoPlayerTest.java | 63 ------------------- .../testutil/FakeVideoRenderer.java | 2 +- 6 files changed, 11 insertions(+), 79 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 537e6e66bd..ad8395d024 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -91,6 +91,7 @@ * Add `TrackSelection.shouldCancelMediaChunkLoad` to check whether an ongoing load should be canceled. Only supported by HLS streams so far. ([#2848](https://github.com/google/ExoPlayer/issues/2848)). + * Remove throws clause from Renderer.stop. * Video: Pass frame rate hint to `Surface.setFrameRate` on Android R devices. * Track selection: * Add `Player.getTrackSelector`. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java index fc2cbbce28..c26e12bcb7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -151,7 +151,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { } @Override - public final void stop() throws ExoPlaybackException { + public final void stop() { Assertions.checkState(state == STATE_STARTED); state = STATE_ENABLED; onStopped(); @@ -255,12 +255,10 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { /** * Called when the renderer is stopped. - *

        - * The default implementation is a no-op. * - * @throws ExoPlaybackException If an error occurs. + *

        The default implementation is a no-op. */ - protected void onStopped() throws ExoPlaybackException { + protected void onStopped() { // Do nothing. } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java index 47ed8cec6a..46961d027f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java @@ -130,7 +130,7 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities } @Override - public final void stop() throws ExoPlaybackException { + public final void stop() { Assertions.checkState(state == STATE_STARTED); state = STATE_ENABLED; onStopped(); @@ -237,12 +237,10 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities /** * Called when the renderer is stopped. - *

        - * The default implementation is a no-op. * - * @throws ExoPlaybackException If an error occurs. + *

        The default implementation is a no-op. */ - protected void onStopped() throws ExoPlaybackException { + protected void onStopped() { // Do nothing. } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index 8620c2d752..fdaa7d5cce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -466,13 +466,11 @@ public interface Renderer extends PlayerMessage.Target { /** * Stops the renderer, transitioning it to the {@link #STATE_ENABLED} state. - *

        - * This method may be called when the renderer is in the following states: - * {@link #STATE_STARTED}. * - * @throws ExoPlaybackException If an error occurs. + *

        This method may be called when the renderer is in the following states: {@link + * #STATE_STARTED}. */ - void stop() throws ExoPlaybackException; + void stop(); /** * Disable the renderer, transitioning it to the {@link #STATE_DISABLED} state. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 9bcbce834a..3f09f71336 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -6089,69 +6089,6 @@ public final class ExoPlayerTest { assertThat(trackSelectionsAfterError.get().get(1)).isNotNull(); // Audio renderer. } - @Test - public void errorThrownDuringRendererDisableAtPeriodTransition_isReportedForCurrentPeriod() { - FakeMediaSource source1 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); - FakeMediaSource source2 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); - FakeRenderer videoRenderer = - new FakeRenderer(C.TRACK_TYPE_VIDEO) { - @Override - protected void onStopped() throws ExoPlaybackException { - // Fail when stopping the renderer. This will happen during the period transition. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.VIDEO_FORMAT); - } - }; - FakeRenderer audioRenderer = new FakeRenderer(C.TRACK_TYPE_AUDIO); - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); - - assertThat(windowIndexAfterError.get()).isEqualTo(0); - assertThat(trackGroupsAfterError.get().length).isEqualTo(1); - assertThat(trackGroupsAfterError.get().get(0).getFormat(0)) - .isEqualTo(ExoPlayerTestRunner.VIDEO_FORMAT); - assertThat(trackSelectionsAfterError.get().get(0)).isNotNull(); // Video renderer. - assertThat(trackSelectionsAfterError.get().get(1)).isNull(); // Audio renderer. - } - // TODO(b/150584930): Fix reporting of renderer errors. @Ignore @Test diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java index 2016f9d000..c7163cf553 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java @@ -63,7 +63,7 @@ public class FakeVideoRenderer extends FakeRenderer { } @Override - protected void onStopped() throws ExoPlaybackException { + protected void onStopped() { super.onStopped(); eventDispatcher.droppedFrames(/* droppedFrameCount= */ 0, /* elapsedMs= */ 0); eventDispatcher.reportVideoFrameProcessingOffset( From f39b1d0f904caaeff09a25266c7876d007beea1a Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 3 Jul 2020 15:12:42 +0100 Subject: [PATCH 0594/1052] Fix TTML ruby decoding to resolve styles by ID The current code only works if the tts:ruby attributes are defined directly on the in-line elements. This changes that so we also consider tts:ruby attributes on `style` nodes referenced by ID. PiperOrigin-RevId: 319515177 --- .../exoplayer2/text/ttml/TtmlNode.java | 2 +- .../exoplayer2/text/ttml/TtmlRenderUtil.java | 23 +++++++++----- .../exoplayer2/text/ttml/TtmlDecoderTest.java | 10 +++++-- testdata/src/test/assets/ttml/rubies.xml | 30 +++++++++++++++---- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java index 02019e8dd5..4c51f97247 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -379,7 +379,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutput.setText(text); } if (resolvedStyle != null) { - TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent); + TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent, globalStyles); regionOutput .setTextAlignment(resolvedStyle.getTextAlign()) .setVerticalType(resolvedStyle.getVerticalType()); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java index e343c57840..13f3fe2b16 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java @@ -78,7 +78,12 @@ import java.util.Map; } public static void applyStylesToSpan( - Spannable builder, int start, int end, TtmlStyle style, @Nullable TtmlNode parent) { + Spannable builder, + int start, + int end, + TtmlStyle style, + @Nullable TtmlNode parent, + Map globalStyles) { if (style.getStyle() != TtmlStyle.UNSPECIFIED) { builder.setSpan(new StyleSpan(style.getStyle()), start, end, @@ -117,12 +122,12 @@ import java.util.Map; switch (style.getRubyType()) { case TtmlStyle.RUBY_TYPE_BASE: // look for the sibling RUBY_TEXT and add it as span between start & end. - @Nullable TtmlNode containerNode = findRubyContainerNode(parent); + @Nullable TtmlNode containerNode = findRubyContainerNode(parent, globalStyles); if (containerNode == null) { // No matching container node break; } - @Nullable TtmlNode textNode = findRubyTextNode(containerNode); + @Nullable TtmlNode textNode = findRubyTextNode(containerNode, globalStyles); if (textNode == null) { // no matching text node break; @@ -200,12 +205,15 @@ import java.util.Map; } @Nullable - private static TtmlNode findRubyTextNode(TtmlNode rubyContainerNode) { + private static TtmlNode findRubyTextNode( + TtmlNode rubyContainerNode, Map globalStyles) { Deque childNodesStack = new ArrayDeque<>(); childNodesStack.push(rubyContainerNode); while (!childNodesStack.isEmpty()) { TtmlNode childNode = childNodesStack.pop(); - if (childNode.style != null && childNode.style.getRubyType() == TtmlStyle.RUBY_TYPE_TEXT) { + @Nullable + TtmlStyle style = resolveStyle(childNode.style, childNode.getStyleIds(), globalStyles); + if (style != null && style.getRubyType() == TtmlStyle.RUBY_TYPE_TEXT) { return childNode; } for (int i = childNode.getChildCount() - 1; i >= 0; i--) { @@ -217,9 +225,10 @@ import java.util.Map; } @Nullable - private static TtmlNode findRubyContainerNode(@Nullable TtmlNode node) { + private static TtmlNode findRubyContainerNode( + @Nullable TtmlNode node, Map globalStyles) { while (node != null) { - @Nullable TtmlStyle style = node.style; + @Nullable TtmlStyle style = resolveStyle(node.style, node.getStyleIds(), globalStyles); if (style != null && style.getRubyType() == TtmlStyle.RUBY_TYPE_CONTAINER) { return node; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java index 9b21261f5f..761814d526 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java @@ -656,15 +656,19 @@ public final class TtmlDecoderTest { Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000); assertThat(thirdCue.toString()).isEqualTo("Cue with annotated text."); - assertThat(thirdCue).hasNoRubySpanBetween(0, thirdCue.length()); + assertThat(thirdCue).hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length()); Spanned fourthCue = getOnlyCueTextAtTimeUs(subtitle, 40_000_000); - assertThat(fourthCue.toString()).isEqualTo("Cue with text."); + assertThat(fourthCue.toString()).isEqualTo("Cue with annotated text."); assertThat(fourthCue).hasNoRubySpanBetween(0, fourthCue.length()); Spanned fifthCue = getOnlyCueTextAtTimeUs(subtitle, 50_000_000); - assertThat(fifthCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(fifthCue.toString()).isEqualTo("Cue with text."); assertThat(fifthCue).hasNoRubySpanBetween(0, fifthCue.length()); + + Spanned sixthCue = getOnlyCueTextAtTimeUs(subtitle, 60_000_000); + assertThat(sixthCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(sixthCue).hasNoRubySpanBetween(0, sixthCue.length()); } private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) { diff --git a/testdata/src/test/assets/ttml/rubies.xml b/testdata/src/test/assets/ttml/rubies.xml index 0eb89da477..874dfebdc3 100644 --- a/testdata/src/test/assets/ttml/rubies.xml +++ b/testdata/src/test/assets/ttml/rubies.xml @@ -16,9 +16,15 @@ --> + + + "); + html.insert(0, htmlHead.toString()); + webView.loadData( Base64.encodeToString(html.toString().getBytes(Charsets.UTF_8), Base64.NO_PADDING), "text/html", diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java index f595d4233b..b9eb6d8e6a 100644 --- a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java @@ -58,27 +58,33 @@ public class SpannedToHtmlConverterTest { "String with colored".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo("String with colored section"); } @Test public void convert_supportsBackgroundColorSpan() { SpannableString spanned = new SpannableString("String with highlighted section"); + int color = Color.argb(51, 64, 32, 16); spanned.setSpan( - new BackgroundColorSpan(Color.argb(51, 64, 32, 16)), + new BackgroundColorSpan(color), "String with ".length(), "String with highlighted".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) - .isEqualTo( - "String with highlighted" - + " section"); + // Double check the color int is being used for the class name as we expect. + assertThat(color).isEqualTo(859840528); + assertThat(htmlAndCss.cssRuleSets) + .containsExactly(".bg_859840528,.bg_859840528 *", "background-color:rgba(64,32,16,0.200);"); + assertThat(htmlAndCss.html) + .isEqualTo("String with highlighted" + " section"); } @Test @@ -90,9 +96,11 @@ public class SpannedToHtmlConverterTest { "Vertical text with 123".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo( "Vertical text with 123 " + "horizontal numbers"); @@ -109,11 +117,14 @@ public class SpannedToHtmlConverterTest { "String with 10px".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); // 10 Android px are converted to 5 CSS px because WebView treats 1 CSS px as 1 Android dp // and we're using screen density xhdpi i.e. density=2. - assertThat(html).isEqualTo("String with 10px section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo("String with 10px section"); } // Set the screen density so we see that px are handled differently to dp. @@ -127,9 +138,12 @@ public class SpannedToHtmlConverterTest { "String with 10dp".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with 10dp section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo("String with 10dp section"); } @Test @@ -141,9 +155,12 @@ public class SpannedToHtmlConverterTest { "String with 10%".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with 10% section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo("String with 10% section"); } @Test @@ -155,9 +172,11 @@ public class SpannedToHtmlConverterTest { "String with Times New Roman".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo( "String with Times New Roman" + " section"); @@ -172,9 +191,11 @@ public class SpannedToHtmlConverterTest { "String with unstyled".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with unstyled section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with unstyled section"); } @Test @@ -186,9 +207,11 @@ public class SpannedToHtmlConverterTest { "String with crossed-out".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo( "String with crossed-out section"); } @@ -213,9 +236,11 @@ public class SpannedToHtmlConverterTest { "String with bold, italic and bold-italic".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo( "String with bold, italic and bold-italic sections."); } @@ -235,9 +260,11 @@ public class SpannedToHtmlConverterTest { "String with over-annotated and under-annotated".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo( "String with " + "" @@ -261,33 +288,39 @@ public class SpannedToHtmlConverterTest { "String with underlined".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with underlined section."); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with underlined section."); } @Test public void convert_escapesHtmlInUnspannedString() { - String html = SpannedToHtmlConverter.convert("String with bold tags", displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert("String with bold tags", displayDensity); - assertThat(html).isEqualTo("String with <b>bold</b> tags"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with <b>bold</b> tags"); } @Test public void convert_handlesLinebreakInUnspannedString() { - String html = + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = SpannedToHtmlConverter.convert( "String with\nnew line and\r\ncrlf style too", displayDensity); - assertThat(html).isEqualTo("String with
        new line and
        crlf style too"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with
        new line and
        crlf style too"); } @Test public void convert_doesntConvertAmpersandLineFeedToBrTag() { - String html = + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = SpannedToHtmlConverter.convert("String with new line ampersand code", displayDensity); - assertThat(html).isEqualTo("String with&#10;new line ampersand code"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with&#10;new line ampersand code"); } @Test @@ -299,27 +332,32 @@ public class SpannedToHtmlConverterTest { "String with unrecognised".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with <foo>unrecognised</foo> tags"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo("String with <foo>unrecognised</foo> tags"); } @Test public void convert_handlesLinebreakInSpannedString() { - String html = + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = SpannedToHtmlConverter.convert( "String with\nnew line and\r\ncrlf style too", displayDensity); - assertThat(html).isEqualTo("String with
        new line and
        crlf style too"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with
        new line and
        crlf style too"); } @Test public void convert_convertsNonAsciiCharactersToAmpersandCodes() { - String html = + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = SpannedToHtmlConverter.convert( new SpannableString("Strìng with 優しいの non-ASCII characters"), displayDensity); - assertThat(html) + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) .isEqualTo("Strìng with 優しいの non-ASCII characters"); } @@ -337,9 +375,11 @@ public class SpannedToHtmlConverterTest { "String with unrecognised".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with unrecognised span"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with unrecognised span"); } @Test @@ -351,9 +391,12 @@ public class SpannedToHtmlConverterTest { spanned.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); spanned.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with italic-bold-underlined section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo("String with italic-bold-underlined section"); } @Test @@ -368,9 +411,11 @@ public class SpannedToHtmlConverterTest { "String with italic and bold".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with italic and bold section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with italic and bold section"); } @Test @@ -387,8 +432,10 @@ public class SpannedToHtmlConverterTest { "String with italic and bold section".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - String html = SpannedToHtmlConverter.convert(spanned, displayDensity); + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); - assertThat(html).isEqualTo("String with italic and bold section"); + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html).isEqualTo("String with italic and bold section"); } } From 21e56f571d7eb6d0e15475aef27926b37f251c4d Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 13 Jul 2020 10:52:56 +0100 Subject: [PATCH 0646/1052] Misc analysis fixes PiperOrigin-RevId: 320921457 --- .../exoplayer2/castdemo/PlayerManager.java | 3 --- .../ext/vp9/LibvpxVideoRenderer.java | 1 - javadoc_library.gradle | 4 ++-- .../android/exoplayer2/SimpleExoPlayer.java | 5 +---- .../exoplayer2/offline/DownloadHelper.java | 1 + .../source/SinglePeriodTimeline.java | 1 - .../cache/CacheDataSourceFactory.java | 1 - .../exoplayer2/util/IntArrayQueue.java | 2 +- .../analytics/AnalyticsCollectorTest.java | 1 - .../offline/DownloadHelperTest.java | 20 ++++++------------- .../source/dash/DashMediaPeriod.java | 2 +- .../source/smoothstreaming/SsMediaPeriod.java | 2 +- .../testutil/FakeAdaptiveMediaPeriod.java | 2 +- 13 files changed, 14 insertions(+), 31 deletions(-) diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java index c5dfe70d93..9dc82e0b23 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java @@ -29,7 +29,6 @@ import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.ext.cast.CastPlayer; import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener; -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; @@ -61,7 +60,6 @@ import java.util.ArrayList; private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY = new DefaultHttpDataSourceFactory(USER_AGENT); - private final DefaultMediaSourceFactory defaultMediaSourceFactory; private final PlayerView localPlayerView; private final PlayerControlView castControlView; private final DefaultTrackSelector trackSelector; @@ -97,7 +95,6 @@ import java.util.ArrayList; trackSelector = new DefaultTrackSelector(context); exoPlayer = new SimpleExoPlayer.Builder(context).setTrackSelector(trackSelector).build(); - defaultMediaSourceFactory = DefaultMediaSourceFactory.newInstance(context, DATA_SOURCE_FACTORY); exoPlayer.addListener(this); localPlayerView.setPlayer(exoPlayer); diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java index 8f95024423..36a8b8c862 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java @@ -102,7 +102,6 @@ public class LibvpxVideoRenderer extends DecoderVideoRenderer { * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. */ - @SuppressWarnings("deprecation") public LibvpxVideoRenderer( long allowedJoiningTimeMs, @Nullable Handler eventHandler, diff --git a/javadoc_library.gradle b/javadoc_library.gradle index f135e3a624..bb17dcb035 100644 --- a/javadoc_library.gradle +++ b/javadoc_library.gradle @@ -16,8 +16,8 @@ apply from: "${buildscript.sourceFile.parentFile}/javadoc_util.gradle" android.libraryVariants.all { variant -> def name = variant.buildType.name - if (!name.equals("release")) { - return; // Skip non-release builds. + if (name != "release") { + return // Skip non-release builds. } def allSourceDirs = variant.sourceSets.inject ([]) { acc, val -> acc << val.javaDirectories diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index d67588285e..20abea0f2d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -497,7 +497,6 @@ public class SimpleExoPlayer extends BasePlayer private final CopyOnWriteArraySet deviceListeners; private final CopyOnWriteArraySet videoDebugListeners; private final CopyOnWriteArraySet audioDebugListeners; - private final BandwidthMeter bandwidthMeter; private final AnalyticsCollector analyticsCollector; private final AudioBecomingNoisyManager audioBecomingNoisyManager; private final AudioFocusManager audioFocusManager; @@ -559,7 +558,6 @@ public class SimpleExoPlayer extends BasePlayer /** @param builder The {@link Builder} to obtain all construction parameters. */ protected SimpleExoPlayer(Builder builder) { - bandwidthMeter = builder.bandwidthMeter; analyticsCollector = builder.analyticsCollector; priorityTaskManager = builder.priorityTaskManager; audioAttributes = builder.audioAttributes; @@ -594,7 +592,7 @@ public class SimpleExoPlayer extends BasePlayer builder.trackSelector, builder.mediaSourceFactory, builder.loadControl, - bandwidthMeter, + builder.bandwidthMeter, analyticsCollector, builder.useLazyPreparation, builder.seekParameters, @@ -1030,7 +1028,6 @@ public class SimpleExoPlayer extends BasePlayer } /** @deprecated Use {@link #setPlaybackSpeed(float)} instead. */ - @SuppressWarnings("deprecation") @Deprecated @RequiresApi(23) public void setPlaybackParams(@Nullable PlaybackParams params) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 80a7a1aade..6e86fd69e0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -919,6 +919,7 @@ public final class DownloadHelper { return DownloadRequest.TYPE_HLS; case C.TYPE_SS: return DownloadRequest.TYPE_SS; + case C.TYPE_OTHER: default: return DownloadRequest.TYPE_PROGRESSIVE; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java index a99ceb6951..54230a8b4f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -266,7 +266,6 @@ public final class SinglePeriodTimeline extends Timeline { } // Provide backwards compatibility. - @SuppressWarnings("deprecation") @Override public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { Assertions.checkIndex(windowIndex, 0, 1); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java index a9348b7d3a..2c51da8a8d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java @@ -45,7 +45,6 @@ public final class CacheDataSourceFactory implements DataSource.Factory { } /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int) */ - @SuppressWarnings("deprecation") public CacheDataSourceFactory( Cache cache, DataSource.Factory upstreamFactory, @CacheDataSource.Flags int flags) { this( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java index 3277d042ed..5deb5f2b60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java @@ -26,7 +26,7 @@ import java.util.NoSuchElementException; public final class IntArrayQueue { /** Default capacity needs to be a power of 2. */ - private static int DEFAULT_INITIAL_CAPACITY = 16; + private static final int DEFAULT_INITIAL_CAPACITY = 16; private int headIndex; private int tailIndex; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index ae76c87e21..9198303185 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -1631,7 +1631,6 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_SEEK_PROCESSED, eventTime)); } - @SuppressWarnings("deprecation") @Override public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { reportedEvents.add(new ReportedEvent(EVENT_PLAYBACK_SPEED_CHANGED, eventTime)); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index 294236edbb..f31fe7c191 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -67,10 +67,6 @@ public class DownloadHelperTest { private static final Format VIDEO_FORMAT_LOW = createVideoFormat(/* bitrate= */ 200_000); private static final Format VIDEO_FORMAT_HIGH = createVideoFormat(/* bitrate= */ 800_000); - private static Format audioFormatUs; - private static Format audioFormatZh; - private static Format textFormatUs; - private static Format textFormatZh; private static final TrackGroup TRACK_GROUP_VIDEO_BOTH = new TrackGroup(VIDEO_FORMAT_LOW, VIDEO_FORMAT_HIGH); @@ -79,35 +75,31 @@ public class DownloadHelperTest { private static TrackGroup trackGroupAudioZh; private static TrackGroup trackGroupTextUs; private static TrackGroup trackGroupTextZh; - - private static TrackGroupArray trackGroupArrayAll; - private static TrackGroupArray trackGroupArraySingle; private static TrackGroupArray[] trackGroupArrays; - private static MediaItem testMediaItem; private DownloadHelper downloadHelper; @BeforeClass public static void staticSetUp() { - audioFormatUs = createAudioFormat(/* language= */ "US"); - audioFormatZh = createAudioFormat(/* language= */ "ZH"); - textFormatUs = createTextFormat(/* language= */ "US"); - textFormatZh = createTextFormat(/* language= */ "ZH"); + Format audioFormatUs = createAudioFormat(/* language= */ "US"); + Format audioFormatZh = createAudioFormat(/* language= */ "ZH"); + Format textFormatUs = createTextFormat(/* language= */ "US"); + Format textFormatZh = createTextFormat(/* language= */ "ZH"); trackGroupAudioUs = new TrackGroup(audioFormatUs); trackGroupAudioZh = new TrackGroup(audioFormatZh); trackGroupTextUs = new TrackGroup(textFormatUs); trackGroupTextZh = new TrackGroup(textFormatZh); - trackGroupArrayAll = + TrackGroupArray trackGroupArrayAll = new TrackGroupArray( TRACK_GROUP_VIDEO_BOTH, trackGroupAudioUs, trackGroupAudioZh, trackGroupTextUs, trackGroupTextZh); - trackGroupArraySingle = + TrackGroupArray trackGroupArraySingle = new TrackGroupArray(TRACK_GROUP_VIDEO_SINGLE, trackGroupAudioUs); trackGroupArrays = new TrackGroupArray[] {trackGroupArrayAll, trackGroupArraySingle}; diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 3d5f05268b..08b4959ebb 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -895,7 +895,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } // We won't assign the array to a variable that erases the generic type, and then write into it. - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({"unchecked"}) private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java index 6fe999661c..81f4a099e4 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java @@ -283,7 +283,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } // We won't assign the array to a variable that erases the generic type, and then write into it. - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({"unchecked"}) private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java index d3eec0b85b..760a1958f0 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java @@ -184,7 +184,7 @@ public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod } // We won't assign the array to a variable that erases the generic type, and then write into it. - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({"unchecked"}) private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } From 5c9c0e207366a3bbe613c3b16691ba925f7c71be Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 13 Jul 2020 14:41:45 +0100 Subject: [PATCH 0647/1052] Improve handling of floating point audio - DefaultAudioSink always supports floating point input. Make it advertise this fact. - Remove the ability to enable/disable floating point output in FfmpegAudioRenderer, since this ability is now also provided on DefaultAudioSink. - Let FfmpegAudioRenderer query the sink to determine whether it will output floating point PCM directly or resample it to 16-bit PCM. PiperOrigin-RevId: 320945360 --- .../ext/ffmpeg/FfmpegAudioRenderer.java | 73 +++++++++---------- .../android/exoplayer2/audio/AudioSink.java | 40 +++++++++- .../audio/DecoderAudioRenderer.java | 12 +++ .../exoplayer2/audio/DefaultAudioSink.java | 33 ++++++--- .../exoplayer2/audio/ForwardingAudioSink.java | 6 ++ .../audio/DefaultAudioSinkTest.java | 38 +++++++++- 6 files changed, 146 insertions(+), 56 deletions(-) diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index f94733f9c4..a29aeb68ae 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.ext.ffmpeg; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_UNSUPPORTED; + import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -22,6 +26,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport; import com.google.android.exoplayer2.audio.DecoderAudioRenderer; import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.drm.ExoMediaCrypto; @@ -41,8 +46,6 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { /** The default input buffer size. */ private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6; - private final boolean enableFloatOutput; - private @MonotonicNonNull FfmpegAudioDecoder decoder; public FfmpegAudioRenderer() { @@ -64,8 +67,7 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { this( eventHandler, eventListener, - new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors), - /* enableFloatOutput= */ false); + new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors)); } /** @@ -75,21 +77,15 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioSink The sink to which audio will be output. - * @param enableFloatOutput Whether to enable 32-bit float audio format, if supported on the - * device/build and if the input format may have bit depth higher than 16-bit. When using - * 32-bit float output, any audio processing will be disabled, including playback speed/pitch - * adjustment. */ public FfmpegAudioRenderer( @Nullable Handler eventHandler, @Nullable AudioRendererEventListener eventListener, - AudioSink audioSink, - boolean enableFloatOutput) { + AudioSink audioSink) { super( eventHandler, eventListener, audioSink); - this.enableFloatOutput = enableFloatOutput; } @Override @@ -103,7 +99,9 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { String mimeType = Assertions.checkNotNull(format.sampleMimeType); if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!FfmpegLibrary.supportsFormat(mimeType) || !isOutputSupported(format)) { + } else if (!FfmpegLibrary.supportsFormat(mimeType) + || (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT) + && !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { return FORMAT_UNSUPPORTED_DRM; @@ -126,7 +124,7 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE; decoder = new FfmpegAudioDecoder( - format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldUseFloatOutput(format)); + format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format)); TraceUtil.endSection(); return decoder; } @@ -142,31 +140,6 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { .build(); } - private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) - || sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT); - } - - private boolean shouldUseFloatOutput(Format inputFormat) { - Assertions.checkNotNull(inputFormat.sampleMimeType); - if (!enableFloatOutput || !sinkSupportsFormat(inputFormat, C.ENCODING_PCM_FLOAT)) { - return false; - } - switch (inputFormat.sampleMimeType) { - case MimeTypes.AUDIO_RAW: - // For raw audio, output in 32-bit float encoding if the bit depth is > 16-bit. - return inputFormat.encoding == C.ENCODING_PCM_24BIT - || inputFormat.encoding == C.ENCODING_PCM_32BIT - || inputFormat.encoding == C.ENCODING_PCM_FLOAT; - case MimeTypes.AUDIO_AC3: - // AC-3 is always 16-bit, so there is no point outputting in 32-bit float encoding. - return false; - default: - // For all other formats, assume that it's worth using 32-bit float encoding. - return true; - } - } - /** * Returns whether the renderer's {@link AudioSink} supports the PCM format that will be output * from the decoder for the given input format and requested output encoding. @@ -175,4 +148,28 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { return sinkSupportsFormat( Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate)); } + + private boolean shouldOutputFloat(Format inputFormat) { + if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) { + // We have no choice because the sink doesn't support 16-bit integer PCM. + return true; + } + + @SinkFormatSupport + int formatSupport = + getSinkFormatSupport( + Util.getPcmFormat( + C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate)); + switch (formatSupport) { + case SINK_FORMAT_SUPPORTED_DIRECTLY: + // AC-3 is always 16-bit, so there's no point using floating point. Assume that it's worth + // using for all other formats. + return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType); + case SINK_FORMAT_UNSUPPORTED: + case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING: + default: + // Always prefer 16-bit PCM if the sink does not provide direct support for floating point. + return false; + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 241ddaebac..2de8dcf8a6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -16,10 +16,14 @@ package com.google.android.exoplayer2.audio; import android.media.AudioTrack; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; /** @@ -172,8 +176,29 @@ public interface AudioSink { } /** - * Returned by {@link #getCurrentPositionUs(boolean)} when the position is not set. + * The level of support the sink provides for a format. One of {@link + * #SINK_FORMAT_SUPPORTED_DIRECTLY}, {@link #SINK_FORMAT_SUPPORTED_WITH_TRANSCODING} or {@link + * #SINK_FORMAT_UNSUPPORTED}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SINK_FORMAT_SUPPORTED_DIRECTLY, + SINK_FORMAT_SUPPORTED_WITH_TRANSCODING, + SINK_FORMAT_UNSUPPORTED + }) + @interface SinkFormatSupport {} + /** The sink supports the format directly, without the need for internal transcoding. */ + int SINK_FORMAT_SUPPORTED_DIRECTLY = 2; + /** + * The sink supports the format, but needs to transcode it internally to do so. Internal + * transcoding may result in lower quality and higher CPU load in some cases. + */ + int SINK_FORMAT_SUPPORTED_WITH_TRANSCODING = 1; + /** The sink does not support the format. */ + int SINK_FORMAT_UNSUPPORTED = 0; + + /** Returned by {@link #getCurrentPositionUs(boolean)} when the position is not set. */ long CURRENT_POSITION_NOT_SET = Long.MIN_VALUE; /** @@ -192,8 +217,17 @@ public interface AudioSink { boolean supportsFormat(Format format); /** - * Returns the playback position in the stream starting at zero, in microseconds, or - * {@link #CURRENT_POSITION_NOT_SET} if it is not yet available. + * Returns the level of support that the sink provides for a given {@link Format}. + * + * @param format The format. + * @return The level of support provided. + */ + @SinkFormatSupport + int getFormatSupport(Format format); + + /** + * Returns the playback position in the stream starting at zero, in microseconds, or {@link + * #CURRENT_POSITION_NOT_SET} if it is not yet available. * * @param sourceEnded Specify {@code true} if no more input buffers will be provided. * @return The playback position relative to the start of playback, in microseconds. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index 0654fa8e27..d3f5dff113 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener.EventDispatcher; +import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport; import com.google.android.exoplayer2.decoder.Decoder; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.decoder.DecoderException; @@ -219,6 +220,17 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media return audioSink.supportsFormat(format); } + /** + * Returns the level of support that the renderer's {@link AudioSink} provides for a given {@link + * Format}. + * + * @see AudioSink#getFormatSupport(Format) (Format) + */ + @SinkFormatSupport + protected final int getSinkFormatSupport(Format format) { + return audioSink.getFormatSupport(format); + } + @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { if (outputStreamEnded) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index dda218fbcc..bba93fe45a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -380,7 +380,7 @@ public final class DefaultAudioSink implements AudioSink { boolean enableOffload) { this.audioCapabilities = audioCapabilities; this.audioProcessorChain = Assertions.checkNotNull(audioProcessorChain); - this.enableFloatOutput = enableFloatOutput; + this.enableFloatOutput = Util.SDK_INT >= 21 && enableFloatOutput; this.enableOffload = Util.SDK_INT >= 29 && enableOffload; releasingConditionVariable = new ConditionVariable(true); audioTrackPositionTracker = new AudioTrackPositionTracker(new PositionTrackerListener()); @@ -420,15 +420,23 @@ public final class DefaultAudioSink implements AudioSink { @Override public boolean supportsFormat(Format format) { + return getFormatSupport(format) != SINK_FORMAT_UNSUPPORTED; + } + + @Override + @SinkFormatSupport + public int getFormatSupport(Format format) { if (format.encoding == C.ENCODING_INVALID) { - return false; + return SINK_FORMAT_UNSUPPORTED; } if (Util.isEncodingLinearPcm(format.encoding)) { - // AudioTrack supports 16-bit integer PCM output in all platform API versions, and float - // output from platform API version 21 only. Other integer PCM encodings are resampled by this - // sink to 16-bit PCM. We assume that the audio framework will downsample any number of - // channels to the output device's required number of channels. - return format.encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; + if (format.encoding == C.ENCODING_PCM_16BIT + || (enableFloatOutput && format.encoding == C.ENCODING_PCM_FLOAT)) { + return SINK_FORMAT_SUPPORTED_DIRECTLY; + } + // We can resample all linear PCM encodings to 16-bit integer PCM, which AudioTrack is + // guaranteed to support. + return SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; } if (enableOffload && isOffloadedPlaybackSupported( @@ -438,9 +446,12 @@ public final class DefaultAudioSink implements AudioSink { audioAttributes, format.encoderDelay, format.encoderPadding)) { - return true; + return SINK_FORMAT_SUPPORTED_DIRECTLY; } - return isPassthroughPlaybackSupported(format); + if (isPassthroughPlaybackSupported(format)) { + return SINK_FORMAT_SUPPORTED_DIRECTLY; + } + return SINK_FORMAT_UNSUPPORTED; } @Override @@ -471,9 +482,7 @@ public final class DefaultAudioSink implements AudioSink { int channelCount = inputFormat.channelCount; @C.Encoding int encoding = inputFormat.encoding; boolean useFloatOutput = - enableFloatOutput - && Util.isEncodingHighResolutionPcm(inputFormat.encoding) - && supportsFormat(inputFormat.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build()); + enableFloatOutput && Util.isEncodingHighResolutionPcm(inputFormat.encoding); AudioProcessor[] availableAudioProcessors = useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; if (processingEnabled) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java index 36459c6f89..43ed7a9a4c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -39,6 +39,12 @@ public class ForwardingAudioSink implements AudioSink { return sink.supportsFormat(format); } + @Override + @SinkFormatSupport + public int getFormatSupport(Format format) { + return sink.getFormatSupport(format); + } + @Override public long getCurrentPositionUs(boolean sourceEnded) { return sink.getCurrentPositionUs(sourceEnded); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index 1266bd2584..6587614b50 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.audio; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; import static com.google.common.truth.Truth.assertThat; import static org.robolectric.annotation.Config.OLDEST_SDK; import static org.robolectric.annotation.Config.TARGET_SDK; @@ -204,16 +206,46 @@ public final class DefaultAudioSinkTest { .isEqualTo(8 * C.MICROS_PER_SECOND); } + @Test + public void floatPcmNeedsTranscodingIfFloatOutputDisabled() { + defaultAudioSink = + new DefaultAudioSink( + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + new AudioProcessor[0], + /* enableFloatOutput= */ false); + Format floatFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build(); + assertThat(defaultAudioSink.getFormatSupport(floatFormat)) + .isEqualTo(SINK_FORMAT_SUPPORTED_WITH_TRANSCODING); + } + @Config(minSdk = OLDEST_SDK, maxSdk = 20) @Test - public void doesNotSupportFloatPcmBeforeApi21() { + public void floatPcmNeedsTranscodingIfFloatOutputEnabledBeforeApi21() { + defaultAudioSink = + new DefaultAudioSink( + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + new AudioProcessor[0], + /* enableFloatOutput= */ true); Format floatFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build(); - assertThat(defaultAudioSink.supportsFormat(floatFormat)).isFalse(); + assertThat(defaultAudioSink.getFormatSupport(floatFormat)) + .isEqualTo(SINK_FORMAT_SUPPORTED_WITH_TRANSCODING); } @Config(minSdk = 21, maxSdk = TARGET_SDK) @Test - public void supportsFloatPcmFromApi21() { + public void floatOutputSupportedIfFloatOutputEnabledFromApi21() { + defaultAudioSink = + new DefaultAudioSink( + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + new AudioProcessor[0], + /* enableFloatOutput= */ true); + Format floatFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build(); + assertThat(defaultAudioSink.getFormatSupport(floatFormat)) + .isEqualTo(SINK_FORMAT_SUPPORTED_DIRECTLY); + } + + @Test + public void supportsFloatPcm() { Format floatFormat = STEREO_44_1_FORMAT.buildUpon().setEncoding(C.ENCODING_PCM_FLOAT).build(); assertThat(defaultAudioSink.supportsFormat(floatFormat)).isTrue(); } From 92437f3a0f5f929909296a7a9e1d106263a840da Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 13 Jul 2020 15:05:00 +0100 Subject: [PATCH 0648/1052] Remove some occurrences of dummy Remove occurrences in comments and private fields. ISSUE: #7565 PiperOrigin-RevId: 320948364 --- .../main/java/com/google/android/exoplayer2/C.java | 2 +- .../source/dash/manifest/DashManifestTest.java | 11 +++++------ .../dash/offline/DownloadManagerDashTest.java | 10 +++++----- .../dash/offline/DownloadServiceDashTest.java | 14 +++++++------- .../exoplayer2/extractor/DummyExtractorOutput.java | 2 +- .../exoplayer2/extractor/DummyTrackOutput.java | 4 +--- .../extractor/mkv/MatroskaExtractor.java | 8 ++++---- .../exoplayer2/extractor/ts/TsExtractor.java | 4 ++-- .../source/hls/BundledHlsMediaChunkExtractor.java | 4 ++-- .../source/hls/HlsSampleStreamWrapper.java | 6 +++--- .../hls/playlist/HlsMediaPlaylistParserTest.java | 2 +- .../exoplayer2/ui/spherical/TouchTrackerTest.java | 12 ++++++------ .../test/assets/mpd/sample_mpd_asset_identifier | 4 ++-- .../src/test/assets/mpd/sample_mpd_event_stream | 4 ++-- .../android/exoplayer2/testutil/FakeTimeline.java | 3 +-- .../exoplayer2/testutil/MediaPeriodAsserts.java | 6 +++--- .../android/exoplayer2/testutil/TestUtil.java | 6 +++--- .../exoplayer2/testutil/FakeDataSourceTest.java | 2 +- 18 files changed, 50 insertions(+), 54 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index 41a2fa5c14..3ebbe8e743 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -690,7 +690,7 @@ public final class C { public static final int TRACK_TYPE_METADATA = 4; /** A type constant for camera motion tracks. */ public static final int TRACK_TYPE_CAMERA_MOTION = 5; - /** A type constant for a dummy or empty track. */ + /** A type constant for a fake or empty track. */ public static final int TRACK_TYPE_NONE = 6; /** * Applications or extensions may define custom {@code TRACK_TYPE_*} constants greater than or diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java index b260bf2cee..a1b971068d 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java @@ -33,9 +33,9 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class DashManifestTest { - private static final UtcTimingElement DUMMY_UTC_TIMING = new UtcTimingElement("", ""); - private static final SingleSegmentBase DUMMY_SEGMENT_BASE = new SingleSegmentBase(); - private static final Format DUMMY_FORMAT = new Format.Builder().build(); + private static final UtcTimingElement UTC_TIMING = new UtcTimingElement("", ""); + private static final SingleSegmentBase SEGMENT_BASE = new SingleSegmentBase(); + private static final Format FORMAT = new Format.Builder().build(); @Test public void copy() { @@ -214,8 +214,7 @@ public class DashManifestTest { } private static Representation newRepresentation() { - return Representation.newInstance( - /* revisionId= */ 0, DUMMY_FORMAT, /* baseUrl= */ "", DUMMY_SEGMENT_BASE); + return Representation.newInstance(/* revisionId= */ 0, FORMAT, /* baseUrl= */ "", SEGMENT_BASE); } private static DashManifest newDashManifest(int duration, Period... periods) { @@ -229,7 +228,7 @@ public class DashManifestTest { /* suggestedPresentationDelayMs= */ 4, /* publishTimeMs= */ 12345, /* programInformation= */ null, - DUMMY_UTC_TIMING, + UTC_TIMING, Uri.EMPTY, Arrays.asList(periods)); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index b16d5727b1..e7c2630da0 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -72,12 +72,12 @@ public class DownloadManagerDashTest { private StreamKey fakeStreamKey2; private TestDownloadManagerListener downloadManagerListener; private DefaultDownloadIndex downloadIndex; - private DummyMainThread dummyMainThread; + private DummyMainThread testThread; @Before public void setUp() throws Exception { ShadowLog.stream = System.out; - dummyMainThread = new DummyMainThread(); + testThread = new DummyMainThread(); Context context = ApplicationProvider.getApplicationContext(); tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); File cacheFolder = new File(tempFolder, "cache"); @@ -105,7 +105,7 @@ public class DownloadManagerDashTest { public void tearDown() { runOnMainThread(() -> downloadManager.release()); Util.recursiveDelete(tempFolder); - dummyMainThread.release(); + testThread.release(); } // Disabled due to flakiness. @@ -144,7 +144,7 @@ public class DownloadManagerDashTest { // Revert fakeDataSet to normal. fakeDataSet.setData(TEST_MPD_URI, TEST_MPD); - dummyMainThread.runOnMainThread(this::createDownloadManager); + testThread.runOnMainThread(this::createDownloadManager); // Block on the test thread. downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); @@ -252,6 +252,6 @@ public class DownloadManagerDashTest { } private void runOnMainThread(TestRunnable r) { - dummyMainThread.runTestOnMainThread(r); + testThread.runTestOnMainThread(r); } } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index 0073b3bfaf..7911ef22fc 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -72,11 +72,11 @@ public class DownloadServiceDashTest { private DownloadService dashDownloadService; private ConditionVariable pauseDownloadCondition; private TestDownloadManagerListener downloadManagerListener; - private DummyMainThread dummyMainThread; + private DummyMainThread testThread; @Before public void setUp() throws IOException { - dummyMainThread = new DummyMainThread(); + testThread = new DummyMainThread(); context = ApplicationProvider.getApplicationContext(); tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); @@ -109,7 +109,7 @@ public class DownloadServiceDashTest { fakeStreamKey1 = new StreamKey(0, 0, 0); fakeStreamKey2 = new StreamKey(0, 1, 0); - dummyMainThread.runTestOnMainThread( + testThread.runTestOnMainThread( () -> { DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider()); @@ -148,9 +148,9 @@ public class DownloadServiceDashTest { @After public void tearDown() { - dummyMainThread.runOnMainThread(() -> dashDownloadService.onDestroy()); + testThread.runOnMainThread(() -> dashDownloadService.onDestroy()); Util.recursiveDelete(tempFolder); - dummyMainThread.release(); + testThread.release(); } @Ignore // b/78877092 @@ -192,7 +192,7 @@ public class DownloadServiceDashTest { } private void removeAll() { - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> { Intent startIntent = DownloadService.buildRemoveDownloadIntent( @@ -212,7 +212,7 @@ public class DownloadServiceDashTest { keysList, /* customCacheKey= */ null, null); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> { Intent startIntent = DownloadService.buildAddDownloadIntent( diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java index f199493500..51fc59fd24 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.extractor; -/** A dummy {@link ExtractorOutput} implementation. */ +/** A fake {@link ExtractorOutput} implementation. */ public final class DummyExtractorOutput implements ExtractorOutput { @Override diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java index 4700bbb480..d52e04c46f 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java @@ -23,9 +23,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; -/** - * A dummy {@link TrackOutput} implementation. - */ +/** A fake {@link TrackOutput} implementation. */ public final class DummyTrackOutput implements TrackOutput { // Even though read data is discarded, data source implementations could be making use of the diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 8634e65176..f566493ada 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -246,8 +246,8 @@ public class MatroskaExtractor implements Extractor { *

        The display time of each subtitle is passed as {@code timeUs} to {@link * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at - * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be replaced with - * the duration of the subtitle. + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. * *

        Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". */ @@ -281,8 +281,8 @@ public class MatroskaExtractor implements Extractor { *

        The display time of each subtitle is passed as {@code timeUs} to {@link * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at - * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be replaced with - * the duration of the subtitle. + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. * *

        Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". */ diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 9734cab1ca..f412a9822f 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -576,8 +576,8 @@ public final class TsExtractor implements Extractor { if (mode == MODE_HLS && id3Reader == null) { // Setup an ID3 track regardless of whether there's a corresponding entry, in case one // appears intermittently during playback. See [Internal: b/20261500]. - EsInfo dummyEsInfo = new EsInfo(TS_STREAM_TYPE_ID3, null, null, Util.EMPTY_BYTE_ARRAY); - id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, dummyEsInfo); + EsInfo id3EsInfo = new EsInfo(TS_STREAM_TYPE_ID3, null, null, Util.EMPTY_BYTE_ARRAY); + id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, id3EsInfo); id3Reader.init(timestampAdjuster, output, new TrackIdGenerator(programNumber, TS_STREAM_TYPE_ID3, MAX_PID_PLUS_ONE)); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java index c5a496c60d..78fc9ae732 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java @@ -37,7 +37,7 @@ import java.io.IOException; */ public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtractor { - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + private static final PositionHolder POSITION_HOLDER = new PositionHolder(); @VisibleForTesting /* package */ final Extractor extractor; private final Format masterPlaylistFormat; @@ -64,7 +64,7 @@ public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtract @Override public boolean read(ExtractorInput extractorInput) throws IOException { - return extractor.read(extractorInput, DUMMY_POSITION_HOLDER) == Extractor.RESULT_CONTINUE; + return extractor.read(extractorInput, POSITION_HOLDER) == Extractor.RESULT_CONTINUE; } @Override diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index aeabff8832..530f9cb366 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -941,7 +941,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (trackOutput == null) { if (tracksEnded) { - return createDummyTrackOutput(id, type); + return createFakeTrackOutput(id, type); } else { // The relevant SampleQueue hasn't been constructed yet - so construct it. trackOutput = createSampleQueue(id, type); @@ -985,7 +985,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } return sampleQueueTrackIds[sampleQueueIndex] == id ? sampleQueues[sampleQueueIndex] - : createDummyTrackOutput(id, type); + : createFakeTrackOutput(id, type); } private SampleQueue createSampleQueue(int id, int type) { @@ -1459,7 +1459,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return true; } - private static DummyTrackOutput createDummyTrackOutput(int id, int type) { + private static DummyTrackOutput createFakeTrackOutput(int id, int type) { Log.w(TAG, "Unmapped track with id " + id + " of type " + type); return new DummyTrackOutput(); } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java index dd8a32b7f0..42b51056cf 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java @@ -321,7 +321,7 @@ public class HlsMediaPlaylistParserTest { + "#EXT-X-KEY:METHOD=NONE\n" + "#EXTINF:5.005,\n" + "#EXT-X-GAP \n" - + "../dummy.ts\n" + + "../test.ts\n" + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://key-service.bamgrid.com/1.0/key?" + "hex-value=9FB8989D15EEAAF8B21B860D7ED3072A\",IV=0x410C8AC18AA42EFA18B5155484F5FC34\n" + "#EXTINF:5.005,\n" diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java index 8147ae89a0..9537c36303 100644 --- a/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java @@ -35,7 +35,7 @@ public class TouchTrackerTest { private TouchTracker tracker; private float yaw; private float pitch; - private float[] dummyMatrix; + private float[] matrix; private static void swipe(TouchTracker tracker, float x0, float y0, float x1, float y1) { tracker.onTouch(null, MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x0, y0, 0)); @@ -54,8 +54,8 @@ public class TouchTrackerTest { yaw = scrollOffsetDegrees.x; }, PX_PER_DEGREES); - dummyMatrix = new float[16]; - tracker.onOrientationChange(dummyMatrix, 0); + matrix = new float[16]; + tracker.onOrientationChange(matrix, 0); } @Test @@ -111,7 +111,7 @@ public class TouchTrackerTest { @Test public void withRoll90() { - tracker.onOrientationChange(dummyMatrix, (float) Math.toRadians(90)); + tracker.onOrientationChange(matrix, (float) Math.toRadians(90)); // Y-axis should now control yaw. swipe(tracker, 0, 0, 0, 2 * SWIPE_PX); @@ -124,7 +124,7 @@ public class TouchTrackerTest { @Test public void withRoll180() { - tracker.onOrientationChange(dummyMatrix, (float) Math.toRadians(180)); + tracker.onOrientationChange(matrix, (float) Math.toRadians(180)); // X-axis should now control reverse yaw. swipe(tracker, 0, 0, -2 * SWIPE_PX, 0); @@ -137,7 +137,7 @@ public class TouchTrackerTest { @Test public void withRoll270() { - tracker.onOrientationChange(dummyMatrix, (float) Math.toRadians(270)); + tracker.onOrientationChange(matrix, (float) Math.toRadians(270)); // Y-axis should now control reverse yaw. swipe(tracker, 0, 0, 0, -2 * SWIPE_PX); diff --git a/testdata/src/test/assets/mpd/sample_mpd_asset_identifier b/testdata/src/test/assets/mpd/sample_mpd_asset_identifier index ff5bc874b9..4c409bd89e 100644 --- a/testdata/src/test/assets/mpd/sample_mpd_asset_identifier +++ b/testdata/src/test/assets/mpd/sample_mpd_asset_identifier @@ -11,13 +11,13 @@ - http://www.dummy.url/ + http://www.test.url/ - http://www.dummy.url/ + http://www.test.url/ diff --git a/testdata/src/test/assets/mpd/sample_mpd_event_stream b/testdata/src/test/assets/mpd/sample_mpd_event_stream index 4148b420f1..15e3e07b89 100644 --- a/testdata/src/test/assets/mpd/sample_mpd_event_stream +++ b/testdata/src/test/assets/mpd/sample_mpd_event_stream @@ -33,13 +33,13 @@ - http://www.dummy.url/ + http://www.test.url/ - http://www.dummy.url/ + http://www.test.url/ diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 303a727061..2d64d2f637 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -52,8 +52,7 @@ public final class FakeTimeline extends Timeline { public final AdPlaybackState adPlaybackState; /** - * Creates a window definition that corresponds to a dummy placeholder timeline using the given - * tag. + * Creates a window definition that corresponds to a placeholder timeline using the given tag. * * @param tag The tag to use in the timeline. */ diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java index 197280159d..eac0ea0f3a 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java @@ -199,9 +199,9 @@ public final class MediaPeriodAsserts { private static TrackGroupArray prepareAndGetTrackGroups(MediaPeriod mediaPeriod) { AtomicReference trackGroupArray = new AtomicReference<>(); - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); ConditionVariable preparedCondition = new ConditionVariable(); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaPeriod.prepare( new Callback() { @@ -222,7 +222,7 @@ public final class MediaPeriodAsserts { } catch (InterruptedException e) { // Ignore. } - dummyMainThread.release(); + testThread.release(); return trackGroupArray.get(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 8be0f305a1..71156b711e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -198,17 +198,17 @@ public class TestUtil { return ImmutableList.copyOf(Bytes.asList(createByteArray(bytes))); } - /** Writes one byte long dummy test data to the file and returns it. */ + /** Writes one byte long test data to the file and returns it. */ public static File createTestFile(File directory, String name) throws IOException { return createTestFile(directory, name, /* length= */ 1); } - /** Writes dummy test data with the specified length to the file and returns it. */ + /** Writes test data with the specified length to the file and returns it. */ public static File createTestFile(File directory, String name, long length) throws IOException { return createTestFile(new File(directory, name), length); } - /** Writes dummy test data with the specified length to the file and returns it. */ + /** Writes test data with the specified length to the file and returns it. */ public static File createTestFile(File file, long length) throws IOException { FileOutputStream output = new FileOutputStream(file); for (long i = 0; i < length; i++) { diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java index b5ecf5440e..18dcd13e42 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java @@ -97,7 +97,7 @@ public final class FakeDataSourceTest { } @Test - public void testDummyData() throws IOException { + public void testFakeData() throws IOException { FakeDataSource dataSource = new FakeDataSource( new FakeDataSet() From 29b12e2f8d28ef9ba5f6a0bd0fb61d161f0dc2fc Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 13 Jul 2020 15:44:12 +0100 Subject: [PATCH 0649/1052] Split SampleQueue.advanceTo into two operations. The method currently advances the read position and returns the number of skipped samples. This prevents checking how many samples are skipped before the operation is executed. Instead, we have a new method that returns the number of to be skipped samples and a skip method that executes the skipping. PiperOrigin-RevId: 320953439 --- .../source/ProgressiveMediaPeriod.java | 8 ++--- .../exoplayer2/source/SampleQueue.java | 29 +++++++++------- .../source/chunk/ChunkSampleStream.java | 16 +++------ .../exoplayer2/source/SampleQueueTest.java | 33 ++++++++++++------- .../source/hls/HlsSampleStreamWrapper.java | 8 ++--- 5 files changed, 48 insertions(+), 46 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 7bc1c36ba7..cd1b49d101 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -499,12 +499,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } maybeNotifyDownstreamFormat(track); SampleQueue sampleQueue = sampleQueues[track]; - int skipCount; - if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { - skipCount = sampleQueue.advanceToEnd(); - } else { - skipCount = sampleQueue.advanceTo(positionUs); - } + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + sampleQueue.skip(skipCount); if (skipCount == 0) { maybeStartDeferredRetry(track); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index ab6e0e3d97..b45d8e06fe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; + import android.os.Looper; import android.util.Log; import androidx.annotation.CallSuper; @@ -402,34 +404,39 @@ public class SampleQueue implements TrackOutput { } /** - * Advances the read position to the keyframe before or at the specified time. + * Returns the number of samples that need to be {@link #skip(int) skipped} to advance the read + * position to the keyframe before or at the specified time. * * @param timeUs The time to advance to. - * @return The number of samples that were skipped, which may be equal to 0. + * @param allowEndOfQueue Whether the end of the queue is considered a keyframe when {@code + * timeUs} is larger than the largest queued timestamp. + * @return The number of samples that need to be skipped, which may be equal to 0. */ - public final synchronized int advanceTo(long timeUs) { + public final synchronized int getSkipCount(long timeUs, boolean allowEndOfQueue) { int relativeReadIndex = getRelativeIndex(readPosition); if (!hasNextSample() || timeUs < timesUs[relativeReadIndex]) { return 0; } + if (timeUs > largestQueuedTimestampUs && allowEndOfQueue) { + return length - readPosition; + } int offset = findSampleBefore(relativeReadIndex, length - readPosition, timeUs, /* keyframe= */ true); if (offset == -1) { return 0; } - readPosition += offset; return offset; } /** - * Advances the read position to the end of the queue. + * Advances the read position by the specified number of samples. * - * @return The number of samples that were skipped. + * @param count The number of samples to advance the read position by. Must be at least 0 and at + * most {@link #getWriteIndex()} - {@link #getReadIndex()}. */ - public final synchronized int advanceToEnd() { - int skipCount = length - readPosition; - readPosition = length; - return skipCount; + public final synchronized void skip(int count) { + checkArgument(count >= 0 && readPosition + count <= length); + readPosition += count; } /** @@ -788,7 +795,7 @@ public class SampleQueue implements TrackOutput { private long discardUpstreamSampleMetadata(int discardFromIndex) { int discardCount = getWriteIndex() - discardFromIndex; - Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); + checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); length -= discardCount; largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); isLastSampleQueued = discardCount == 0 && isLastSampleQueued; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index 2491432bb7..9238ef1c7c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -392,12 +392,8 @@ public class ChunkSampleStream implements SampleStream, S if (isPendingReset()) { return 0; } - int skipCount; - if (loadingFinished && positionUs > primarySampleQueue.getLargestQueuedTimestampUs()) { - skipCount = primarySampleQueue.advanceToEnd(); - } else { - skipCount = primarySampleQueue.advanceTo(positionUs); - } + int skipCount = primarySampleQueue.getSkipCount(positionUs, loadingFinished); + primarySampleQueue.skip(skipCount); maybeNotifyPrimaryTrackFormatChanged(); return skipCount; } @@ -789,12 +785,8 @@ public class ChunkSampleStream implements SampleStream, S return 0; } maybeNotifyDownstreamFormat(); - int skipCount; - if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { - skipCount = sampleQueue.advanceToEnd(); - } else { - skipCount = sampleQueue.advanceTo(positionUs); - } + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + sampleQueue.skip(skipCount); return skipCount; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 16444b99bf..4583c542b3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -21,6 +21,7 @@ import static com.google.android.exoplayer2.C.RESULT_BUFFER_READ; import static com.google.android.exoplayer2.C.RESULT_FORMAT_READ; import static com.google.android.exoplayer2.C.RESULT_NOTHING_READ; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Long.MAX_VALUE; import static java.lang.Long.MIN_VALUE; import static java.util.Arrays.copyOfRange; import static org.junit.Assert.assertArrayEquals; @@ -590,9 +591,10 @@ public final class SampleQueueTest { } @Test - public void advanceToEnd() { + public void skipToEnd() { writeTestData(); - sampleQueue.advanceToEnd(); + sampleQueue.skip( + sampleQueue.getSkipCount(/* timeUs= */ MAX_VALUE, /* allowEndOfQueue= */ true)); assertAllocationCount(10); sampleQueue.discardToRead(); assertAllocationCount(0); @@ -604,10 +606,11 @@ public final class SampleQueueTest { } @Test - public void advanceToEndRetainsUnassignedData() { + public void skipToEndRetainsUnassignedData() { sampleQueue.format(FORMAT_1); sampleQueue.sampleData(new ParsableByteArray(DATA), ALLOCATION_SIZE); - sampleQueue.advanceToEnd(); + sampleQueue.skip( + sampleQueue.getSkipCount(/* timeUs= */ MAX_VALUE, /* allowEndOfQueue= */ true)); assertAllocationCount(1); sampleQueue.discardToRead(); // Skipping shouldn't discard data that may belong to a sample whose metadata has yet to be @@ -635,41 +638,47 @@ public final class SampleQueueTest { } @Test - public void advanceToBeforeBuffer() { + public void skipToBeforeBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(SAMPLE_TIMESTAMPS[0] - 1); + int skipCount = + sampleQueue.getSkipCount(SAMPLE_TIMESTAMPS[0] - 1, /* allowEndOfQueue= */ false); // Should have no effect (we're already at the first frame). assertThat(skipCount).isEqualTo(0); + sampleQueue.skip(skipCount); assertReadTestData(); assertNoSamplesToRead(FORMAT_2); } @Test - public void advanceToStartOfBuffer() { + public void skipToStartOfBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(SAMPLE_TIMESTAMPS[0]); + int skipCount = sampleQueue.getSkipCount(SAMPLE_TIMESTAMPS[0], /* allowEndOfQueue= */ false); // Should have no effect (we're already at the first frame). assertThat(skipCount).isEqualTo(0); + sampleQueue.skip(skipCount); assertReadTestData(); assertNoSamplesToRead(FORMAT_2); } @Test - public void advanceToEndOfBuffer() { + public void skipToEndOfBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(LAST_SAMPLE_TIMESTAMP); + int skipCount = sampleQueue.getSkipCount(LAST_SAMPLE_TIMESTAMP, /* allowEndOfQueue= */ false); // Should advance to 2nd keyframe (the 4th frame). assertThat(skipCount).isEqualTo(4); + sampleQueue.skip(skipCount); assertReadTestData(/* startFormat= */ null, DATA_SECOND_KEYFRAME_INDEX); assertNoSamplesToRead(FORMAT_2); } @Test - public void advanceToAfterBuffer() { + public void skipToAfterBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(LAST_SAMPLE_TIMESTAMP + 1); + int skipCount = + sampleQueue.getSkipCount(LAST_SAMPLE_TIMESTAMP + 1, /* allowEndOfQueue= */ false); // Should advance to 2nd keyframe (the 4th frame). assertThat(skipCount).isEqualTo(4); + sampleQueue.skip(skipCount); assertReadTestData(/* startFormat= */ null, DATA_SECOND_KEYFRAME_INDEX); assertNoSamplesToRead(FORMAT_2); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 530f9cb366..e7f55807f8 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -595,11 +595,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } SampleQueue sampleQueue = sampleQueues[sampleQueueIndex]; - if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { - return sampleQueue.advanceToEnd(); - } else { - return sampleQueue.advanceTo(positionUs); - } + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + sampleQueue.skip(skipCount); + return skipCount; } // SequenceableLoader implementation From f2055396169c173fbc8ba4f63c352d07d73ce65f Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 13 Jul 2020 16:33:48 +0100 Subject: [PATCH 0650/1052] Use lambdas where possible PiperOrigin-RevId: 320960833 --- .../media2/SessionPlayerConnectorTest.java | 19 +- .../ext/media2/SessionPlayerConnector.java | 6 +- .../drm/DefaultDrmSessionManager.java | 4 +- .../exoplayer2/drm/FrameworkMediaDrm.java | 5 +- .../android/exoplayer2/ExoPlayerTest.java | 4 +- .../audio/MediaCodecAudioRendererTest.java | 10 +- .../offline/DownloadManagerTest.java | 10 +- .../video/MediaCodecVideoRendererTest.java | 10 +- .../ui/StyledPlayerControlView.java | 246 +++++++--------- .../StyledPlayerControlViewLayoutManager.java | 274 +++++++++--------- 10 files changed, 265 insertions(+), 323 deletions(-) diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java index 51f2695bf7..f0f00f97eb 100644 --- a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java @@ -1146,17 +1146,16 @@ public class SessionPlayerConnectorTest { assertPlayerResultSuccess(sessionPlayerConnector.prepare()); InstrumentationRegistry.getInstrumentation() .runOnMainSync( - () -> { - simpleExoPlayer.addListener( - new Player.EventListener() { - @Override - public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { - if (playWhenReady) { - simpleExoPlayer.setPlayWhenReady(false); + () -> + simpleExoPlayer.addListener( + new Player.EventListener() { + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + if (playWhenReady) { + simpleExoPlayer.setPlayWhenReady(false); + } } - } - }); - }); + })); assertPlayerResultSuccess(sessionPlayerConnector.play()); assertThat( diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java index f3cb937830..8c0b1bfbb1 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java @@ -149,19 +149,19 @@ public final class SessionPlayerConnector extends SessionPlayer { @Override public ListenableFuture play() { return playerCommandQueue.addCommand( - PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY, /* command= */ () -> player.play()); + PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY, /* command= */ player::play); } @Override public ListenableFuture pause() { return playerCommandQueue.addCommand( - PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE, /* command= */ () -> player.pause()); + PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE, /* command= */ player::pause); } @Override public ListenableFuture prepare() { return playerCommandQueue.addCommand( - PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE, /* command= */ () -> player.prepare()); + PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE, /* command= */ player::prepare); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index becf548a45..db98e9401b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -771,9 +771,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { keepaliveSessions.add(session); Assertions.checkNotNull(sessionReleasingHandler) .postAtTime( - () -> { - session.release(/* eventDispatcher= */ null); - }, + () -> session.release(/* eventDispatcher= */ null), session, /* uptimeMillis= */ SystemClock.uptimeMillis() + sessionKeepaliveMs); } else if (newReferenceCount == 0) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index ca4c175b15..fea3a884d0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -156,9 +156,8 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { mediaDrm.setOnExpirationUpdateListener( listener == null ? null - : (mediaDrm, sessionId, expirationTimeMs) -> { - listener.onExpirationUpdate(FrameworkMediaDrm.this, sessionId, expirationTimeMs); - }, + : (mediaDrm, sessionId, expirationTimeMs) -> + listener.onExpirationUpdate(FrameworkMediaDrm.this, sessionId, expirationTimeMs), /* handler= */ null); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index ac7a082bec..6466babe66 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -1913,9 +1913,7 @@ public final class ExoPlayerTest { .waitForTimelineChanged() .pause() .sendMessage( - (messageType, payload) -> { - counter.getAndIncrement(); - }, + (messageType, payload) -> counter.getAndIncrement(), /* windowIndex= */ 0, /* positionMs= */ 2000, /* deleteAfterDelivery= */ false) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java index cf18ffef54..f4c1e12845 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -40,7 +40,6 @@ import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.collect.ImmutableList; import java.util.Collections; -import java.util.List; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -78,11 +77,8 @@ public class MediaCodecAudioRendererTest { when(audioSink.handleBuffer(any(), anyLong(), anyInt())).thenReturn(true); mediaCodecSelector = - new MediaCodecSelector() { - @Override - public List getDecoderInfos( - String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) { - return Collections.singletonList( + (mimeType, requiresSecureDecoder, requiresTunnelingDecoder) -> + Collections.singletonList( MediaCodecInfo.newInstance( /* name= */ "name", /* mimeType= */ mimeType, @@ -93,8 +89,6 @@ public class MediaCodecAudioRendererTest { /* vendor= */ false, /* forceDisableAdaptive= */ false, /* forceSecure= */ false)); - } - }; mediaCodecAudioRenderer = new MediaCodecAudioRenderer( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java index 55bfa35560..6ec93cbc29 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java @@ -711,19 +711,13 @@ public class DownloadManagerTest { private List postGetCurrentDownloads() { AtomicReference> currentDownloadsReference = new AtomicReference<>(); - runOnMainThread( - () -> { - currentDownloadsReference.set(downloadManager.getCurrentDownloads()); - }); + runOnMainThread(() -> currentDownloadsReference.set(downloadManager.getCurrentDownloads())); return currentDownloadsReference.get(); } private DownloadIndex postGetDownloadIndex() { AtomicReference downloadIndexReference = new AtomicReference<>(); - runOnMainThread( - () -> { - downloadIndexReference.set(downloadManager.getDownloadIndex()); - }); + runOnMainThread(() -> downloadIndexReference.set(downloadManager.getDownloadIndex())); return downloadIndexReference.get(); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java index 2a9e149eda..2b6b6369cc 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java @@ -50,7 +50,6 @@ import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamI import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.collect.ImmutableList; import java.util.Collections; -import java.util.List; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -82,11 +81,8 @@ public class MediaCodecVideoRendererTest { @Before public void setUp() throws Exception { MediaCodecSelector mediaCodecSelector = - new MediaCodecSelector() { - @Override - public List getDecoderInfos( - String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) { - return Collections.singletonList( + (mimeType, requiresSecureDecoder, requiresTunnelingDecoder) -> + Collections.singletonList( MediaCodecInfo.newInstance( /* name= */ "name", /* mimeType= */ mimeType, @@ -97,8 +93,6 @@ public class MediaCodecVideoRendererTest { /* vendor= */ false, /* forceDisableAdaptive= */ false, /* forceSecure= */ false)); - } - }; mediaCodecVideoRenderer = new MediaCodecVideoRenderer( diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java index 2c40e7ef70..a33a508d89 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -558,7 +558,7 @@ public class StyledPlayerControlView extends FrameLayout { } fullScreenButton = findViewById(R.id.exo_fullscreen); if (fullScreenButton != null) { - fullScreenButton.setOnClickListener(fullScreenModeChangedListener); + fullScreenButton.setOnClickListener(this::onFullScreenButtonClicked); } settingsButton = findViewById(R.id.exo_settings); if (settingsButton != null) { @@ -648,7 +648,7 @@ public class StyledPlayerControlView extends FrameLayout { String normalSpeed = resources.getString(R.string.exo_controls_playback_speed_normal); selectedPlaybackSpeedIndex = playbackSpeedTextList.indexOf(normalSpeed); - playbackSpeedMultBy100List = new ArrayList(); + playbackSpeedMultBy100List = new ArrayList<>(); int[] speeds = resources.getIntArray(R.array.exo_speed_multiplied_by_100); for (int speed : speeds) { playbackSpeedMultBy100List.add(speed); @@ -699,34 +699,7 @@ public class StyledPlayerControlView extends FrameLayout { shuffleOffContentDescription = resources.getString(R.string.exo_controls_shuffle_off_description); - addOnLayoutChangeListener( - new OnLayoutChangeListener() { - @Override - public void onLayoutChange( - View v, - int left, - int top, - int right, - int bottom, - int oldLeft, - int oldTop, - int oldRight, - int oldBottom) { - int width = right - left; - int height = bottom - top; - int oldWidth = oldRight - oldLeft; - int oldHeight = oldBottom - oldTop; - - if ((width != oldWidth || height != oldHeight) && settingsWindow.isShowing()) { - updateSettingsWindowSize(); - - int xoff = getWidth() - settingsWindow.getWidth() - settingsWindowMargin; - int yoff = -settingsWindow.getHeight() - settingsWindowMargin; - - settingsWindow.update(v, xoff, yoff, -1, -1); - } - } - }); + addOnLayoutChangeListener(this::onLayoutChange); } @SuppressWarnings("ResourceType") @@ -1545,29 +1518,47 @@ public class StyledPlayerControlView extends FrameLayout { return controlDispatcher.dispatchSeekTo(player, windowIndex, positionMs); } - private final OnClickListener fullScreenModeChangedListener = - new OnClickListener() { + private void onFullScreenButtonClicked(View v) { + if (onFullScreenModeChangedListener == null || fullScreenButton == null) { + return; + } - @Override - public void onClick(View v) { - if (onFullScreenModeChangedListener == null || fullScreenButton == null) { - return; - } + isFullScreen = !isFullScreen; + if (isFullScreen) { + fullScreenButton.setImageDrawable(fullScreenExitDrawable); + fullScreenButton.setContentDescription(fullScreenExitContentDescription); + } else { + fullScreenButton.setImageDrawable(fullScreenEnterDrawable); + fullScreenButton.setContentDescription(fullScreenEnterContentDescription); + } - isFullScreen = !isFullScreen; - if (isFullScreen) { - fullScreenButton.setImageDrawable(fullScreenExitDrawable); - fullScreenButton.setContentDescription(fullScreenExitContentDescription); - } else { - fullScreenButton.setImageDrawable(fullScreenEnterDrawable); - fullScreenButton.setContentDescription(fullScreenEnterContentDescription); - } + if (onFullScreenModeChangedListener != null) { + onFullScreenModeChangedListener.onFullScreenModeChanged(isFullScreen); + } + } - if (onFullScreenModeChangedListener != null) { - onFullScreenModeChangedListener.onFullScreenModeChanged(isFullScreen); - } - } - }; + private void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + int width = right - left; + int height = bottom - top; + int oldWidth = oldRight - oldLeft; + int oldHeight = oldBottom - oldTop; + + if ((width != oldWidth || height != oldHeight) && settingsWindow.isShowing()) { + updateSettingsWindowSize(); + int xOffset = getWidth() - settingsWindow.getWidth() - settingsWindowMargin; + int yOffset = -settingsWindow.getHeight() - settingsWindowMargin; + settingsWindow.update(v, xOffset, yOffset, -1, -1); + } + } @Override public void onAttachedToWindow() { @@ -1868,25 +1859,22 @@ public class StyledPlayerControlView extends FrameLayout { iconView = itemView.findViewById(R.id.exo_icon); itemView.setOnClickListener( - new OnClickListener() { - @Override - public void onClick(View v) { - int position = SettingsViewHolder.this.getAdapterPosition(); - if (position == RecyclerView.NO_POSITION) { - return; - } + v -> { + int position = SettingsViewHolder.this.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } - if (position == SETTINGS_PLAYBACK_SPEED_POSITION) { - subSettingsAdapter.setTexts(playbackSpeedTextList); - subSettingsAdapter.setCheckPosition(selectedPlaybackSpeedIndex); - selectedMainSettingsPosition = SETTINGS_PLAYBACK_SPEED_POSITION; - displaySettingsWindow(subSettingsAdapter); - } else if (position == SETTINGS_AUDIO_TRACK_SELECTION_POSITION) { - selectedMainSettingsPosition = SETTINGS_AUDIO_TRACK_SELECTION_POSITION; - displaySettingsWindow(audioTrackSelectionAdapter); - } else { - settingsWindow.dismiss(); - } + if (position == SETTINGS_PLAYBACK_SPEED_POSITION) { + subSettingsAdapter.setTexts(playbackSpeedTextList); + subSettingsAdapter.setCheckPosition(selectedPlaybackSpeedIndex); + selectedMainSettingsPosition = SETTINGS_PLAYBACK_SPEED_POSITION; + displaySettingsWindow(subSettingsAdapter); + } else if (position == SETTINGS_AUDIO_TRACK_SELECTION_POSITION) { + selectedMainSettingsPosition = SETTINGS_AUDIO_TRACK_SELECTION_POSITION; + displaySettingsWindow(audioTrackSelectionAdapter); + } else { + settingsWindow.dismiss(); } }); } @@ -1938,22 +1926,19 @@ public class StyledPlayerControlView extends FrameLayout { checkView = itemView.findViewById(R.id.exo_check); itemView.setOnClickListener( - new OnClickListener() { - @Override - public void onClick(View v) { - int position = SubSettingsViewHolder.this.getAdapterPosition(); - if (position == RecyclerView.NO_POSITION) { - return; - } - - if (selectedMainSettingsPosition == SETTINGS_PLAYBACK_SPEED_POSITION) { - if (position != selectedPlaybackSpeedIndex) { - float speed = playbackSpeedMultBy100List.get(position) / 100.0f; - setPlaybackSpeed(speed); - } - } - settingsWindow.dismiss(); + v -> { + int position = SubSettingsViewHolder.this.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; } + + if (selectedMainSettingsPosition == SETTINGS_PLAYBACK_SPEED_POSITION) { + if (position != selectedPlaybackSpeedIndex) { + float speed = playbackSpeedMultBy100List.get(position) / 100.0f; + setPlaybackSpeed(speed); + } + } + settingsWindow.dismiss(); }); } } @@ -2012,21 +1997,18 @@ public class StyledPlayerControlView extends FrameLayout { } holder.checkView.setVisibility(isTrackSelectionOff ? VISIBLE : INVISIBLE); holder.itemView.setOnClickListener( - new OnClickListener() { - @Override - public void onClick(View v) { - if (trackSelector != null) { - ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); - for (int i = 0; i < rendererIndices.size(); i++) { - int rendererIndex = rendererIndices.get(i); - parametersBuilder = - parametersBuilder - .clearSelectionOverrides(rendererIndex) - .setRendererDisabled(rendererIndex, true); - } - checkNotNull(trackSelector).setParameters(parametersBuilder); - settingsWindow.dismiss(); + v -> { + if (trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + parametersBuilder = + parametersBuilder + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, true); } + checkNotNull(trackSelector).setParameters(parametersBuilder); + settingsWindow.dismiss(); } }); } @@ -2065,22 +2047,19 @@ public class StyledPlayerControlView extends FrameLayout { } holder.checkView.setVisibility(hasSelectionOverride ? INVISIBLE : VISIBLE); holder.itemView.setOnClickListener( - new OnClickListener() { - @Override - public void onClick(View v) { - if (trackSelector != null) { - ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); - for (int i = 0; i < rendererIndices.size(); i++) { - int rendererIndex = rendererIndices.get(i); - parametersBuilder = parametersBuilder.clearSelectionOverrides(rendererIndex); - } - checkNotNull(trackSelector).setParameters(parametersBuilder); + v -> { + if (trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + parametersBuilder = parametersBuilder.clearSelectionOverrides(rendererIndex); } - settingsAdapter.updateSubTexts( - SETTINGS_AUDIO_TRACK_SELECTION_POSITION, - getResources().getString(R.string.exo_track_selection_auto)); - settingsWindow.dismiss(); + checkNotNull(trackSelector).setParameters(parametersBuilder); } + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_auto)); + settingsWindow.dismiss(); }); } @@ -2176,32 +2155,29 @@ public class StyledPlayerControlView extends FrameLayout { holder.textView.setText(track.trackName); holder.checkView.setVisibility(explicitlySelected ? VISIBLE : INVISIBLE); holder.itemView.setOnClickListener( - new OnClickListener() { - @Override - public void onClick(View v) { - if (mappedTrackInfo != null && trackSelector != null) { - ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); - for (int i = 0; i < rendererIndices.size(); i++) { - int rendererIndex = rendererIndices.get(i); - if (rendererIndex == track.rendererIndex) { - parametersBuilder = - parametersBuilder - .setSelectionOverride( - rendererIndex, - checkNotNull(mappedTrackInfo).getTrackGroups(rendererIndex), - new SelectionOverride(track.groupIndex, track.trackIndex)) - .setRendererDisabled(rendererIndex, false); - } else { - parametersBuilder = - parametersBuilder - .clearSelectionOverrides(rendererIndex) - .setRendererDisabled(rendererIndex, true); - } + v -> { + if (mappedTrackInfo != null && trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + if (rendererIndex == track.rendererIndex) { + parametersBuilder = + parametersBuilder + .setSelectionOverride( + rendererIndex, + checkNotNull(mappedTrackInfo).getTrackGroups(rendererIndex), + new SelectionOverride(track.groupIndex, track.trackIndex)) + .setRendererDisabled(rendererIndex, false); + } else { + parametersBuilder = + parametersBuilder + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, true); } - checkNotNull(trackSelector).setParameters(parametersBuilder); - onTrackSelection(track.trackName); - settingsWindow.dismiss(); } + checkNotNull(trackSelector).setParameters(parametersBuilder); + onTrackSelection(track.trackName); + settingsWindow.dismiss(); } }); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java index d7ec1abc9d..d6e3a5b73e 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java @@ -22,15 +22,14 @@ import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.content.res.Resources; import android.view.View; -import android.view.View.OnClickListener; +import android.view.View.OnLayoutChangeListener; import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; import android.view.animation.LinearInterpolator; import androidx.annotation.Nullable; import java.util.ArrayList; -/* package */ final class StyledPlayerControlViewLayoutManager - implements View.OnLayoutChangeListener { +/* package */ final class StyledPlayerControlViewLayoutManager { private static final long ANIMATION_INTERVAL_MS = 2_000; private static final long DURATION_FOR_HIDING_ANIMATION_MS = 250; private static final long DURATION_FOR_SHOWING_ANIMATION_MS = 250; @@ -47,6 +46,13 @@ import java.util.ArrayList; // Int for defining the UX state where the views are being animated to be shown. private static final int UX_STATE_ANIMATING_SHOW = 4; + private final Runnable showAllBarsRunnable; + private final Runnable hideAllBarsRunnable; + private final Runnable hideProgressBarRunnable; + private final Runnable hideMainBarsRunnable; + private final Runnable hideControllerRunnable; + private final OnLayoutChangeListener onLayoutChangeListener; + private int uxState = UX_STATE_ALL_VISIBLE; private boolean isMinimalMode; private boolean needToShowBars; @@ -73,7 +79,16 @@ import java.util.ArrayList; @Nullable private ValueAnimator overflowShowAnimator; @Nullable private ValueAnimator overflowHideAnimator; - void show() { + public StyledPlayerControlViewLayoutManager() { + showAllBarsRunnable = this::showAllBars; + hideAllBarsRunnable = this::hideAllBars; + hideProgressBarRunnable = this::hideProgressBar; + hideMainBarsRunnable = this::hideMainBars; + hideControllerRunnable = this::hideController; + onLayoutChangeListener = this::onLayoutChange; + } + + public void show() { if (this.styledPlayerControlView == null) { return; } @@ -83,10 +98,10 @@ import java.util.ArrayList; styledPlayerControlView.updateAll(); styledPlayerControlView.requestPlayPauseFocus(); } - styledPlayerControlView.post(showAllBars); + styledPlayerControlView.post(showAllBarsRunnable); } - void hide() { + public void hide() { if (styledPlayerControlView == null || uxState == UX_STATE_ANIMATING_HIDE || uxState == UX_STATE_NONE_VISIBLE) { @@ -94,23 +109,23 @@ import java.util.ArrayList; } removeHideCallbacks(); if (!animationEnabled) { - postDelayedRunnable(hideController, 0); + postDelayedRunnable(hideControllerRunnable, 0); } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { - postDelayedRunnable(hideProgressBar, 0); + postDelayedRunnable(hideProgressBarRunnable, 0); } else { - postDelayedRunnable(hideAllBars, 0); + postDelayedRunnable(hideAllBarsRunnable, 0); } } - void setAnimationEnabled(boolean animationEnabled) { + public void setAnimationEnabled(boolean animationEnabled) { this.animationEnabled = animationEnabled; } - boolean isAnimationEnabled() { + public boolean isAnimationEnabled() { return animationEnabled; } - void resetHideCallbacks() { + public void resetHideCallbacks() { if (uxState == UX_STATE_ANIMATING_HIDE) { return; } @@ -119,29 +134,29 @@ import java.util.ArrayList; styledPlayerControlView != null ? styledPlayerControlView.getShowTimeoutMs() : 0; if (showTimeoutMs > 0) { if (!animationEnabled) { - postDelayedRunnable(hideController, showTimeoutMs); + postDelayedRunnable(hideControllerRunnable, showTimeoutMs); } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { - postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); + postDelayedRunnable(hideProgressBarRunnable, ANIMATION_INTERVAL_MS); } else { - postDelayedRunnable(hideMainBars, showTimeoutMs); + postDelayedRunnable(hideMainBarsRunnable, showTimeoutMs); } } } - void removeHideCallbacks() { + public void removeHideCallbacks() { if (styledPlayerControlView == null) { return; } - styledPlayerControlView.removeCallbacks(hideController); - styledPlayerControlView.removeCallbacks(hideAllBars); - styledPlayerControlView.removeCallbacks(hideMainBars); - styledPlayerControlView.removeCallbacks(hideProgressBar); + styledPlayerControlView.removeCallbacks(hideControllerRunnable); + styledPlayerControlView.removeCallbacks(hideAllBarsRunnable); + styledPlayerControlView.removeCallbacks(hideMainBarsRunnable); + styledPlayerControlView.removeCallbacks(hideProgressBarRunnable); } - void onViewAttached(StyledPlayerControlView v) { + public void onViewAttached(StyledPlayerControlView v) { styledPlayerControlView = v; - v.addOnLayoutChangeListener(this); + v.addOnLayoutChangeListener(onLayoutChangeListener); // Relating to Title Bar View ViewGroup titleBar = v.findViewById(R.id.exo_title_bar); @@ -167,8 +182,8 @@ import java.util.ArrayList; overflowShowButton = v.findViewById(R.id.exo_overflow_show); View overflowHideButton = v.findViewById(R.id.exo_overflow_hide); if (overflowShowButton != null && overflowHideButton != null) { - overflowShowButton.setOnClickListener(overflowListener); - overflowHideButton.setOnClickListener(overflowListener); + overflowShowButton.setOnClickListener(this::onOverflowButtonClick); + overflowHideButton.setOnClickListener(this::onOverflowButtonClick); } this.titleBar = titleBar; @@ -256,7 +271,7 @@ import java.util.ArrayList; setUxState(UX_STATE_ONLY_PROGRESS_VISIBLE); if (needToShowBars) { if (styledPlayerControlView != null) { - styledPlayerControlView.post(showAllBars); + styledPlayerControlView.post(showAllBarsRunnable); } needToShowBars = false; } @@ -282,7 +297,7 @@ import java.util.ArrayList; setUxState(UX_STATE_NONE_VISIBLE); if (needToShowBars) { if (styledPlayerControlView != null) { - styledPlayerControlView.post(showAllBars); + styledPlayerControlView.post(showAllBarsRunnable); } needToShowBars = false; } @@ -306,7 +321,7 @@ import java.util.ArrayList; setUxState(UX_STATE_NONE_VISIBLE); if (needToShowBars) { if (styledPlayerControlView != null) { - styledPlayerControlView.post(showAllBars); + styledPlayerControlView.post(showAllBarsRunnable); } needToShowBars = false; } @@ -403,11 +418,11 @@ import java.util.ArrayList; }); } - void onViewDetached(StyledPlayerControlView v) { - v.removeOnLayoutChangeListener(this); + public void onViewDetached(StyledPlayerControlView v) { + v.removeOnLayoutChangeListener(onLayoutChangeListener); } - boolean isFullyVisible() { + public boolean isFullyVisible() { if (styledPlayerControlView == null) { return false; } @@ -432,80 +447,91 @@ import java.util.ArrayList; } } - private final Runnable showAllBars = - new Runnable() { - @Override - public void run() { - if (!animationEnabled) { - setUxState(UX_STATE_ALL_VISIBLE); - resetHideCallbacks(); - return; - } + private void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { - switch (uxState) { - case UX_STATE_NONE_VISIBLE: - if (showAllBarsAnimator != null) { - showAllBarsAnimator.start(); - } - break; - case UX_STATE_ONLY_PROGRESS_VISIBLE: - if (showMainBarsAnimator != null) { - showMainBarsAnimator.start(); - } - break; - case UX_STATE_ANIMATING_HIDE: - needToShowBars = true; - break; - case UX_STATE_ANIMATING_SHOW: - return; - default: - break; - } - resetHideCallbacks(); - } - }; + boolean shouldBeMinimalMode = shouldBeMinimalMode(); + if (isMinimalMode != shouldBeMinimalMode) { + isMinimalMode = shouldBeMinimalMode; + v.post(this::updateLayoutForSizeChange); + } + boolean widthChanged = (right - left) != (oldRight - oldLeft); + if (!isMinimalMode && widthChanged) { + v.post(this::onLayoutWidthChanged); + } + } - private final Runnable hideAllBars = - new Runnable() { - @Override - public void run() { - if (hideAllBarsAnimator == null) { - return; - } - hideAllBarsAnimator.start(); - } - }; + private void onOverflowButtonClick(View v) { + resetHideCallbacks(); + if (v.getId() == R.id.exo_overflow_show && overflowShowAnimator != null) { + overflowShowAnimator.start(); + } else if (v.getId() == R.id.exo_overflow_hide && overflowHideAnimator != null) { + overflowHideAnimator.start(); + } + } - private final Runnable hideMainBars = - new Runnable() { - @Override - public void run() { - if (hideMainBarsAnimator == null) { - return; - } - hideMainBarsAnimator.start(); - postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); - } - }; + private void showAllBars() { + if (!animationEnabled) { + setUxState(UX_STATE_ALL_VISIBLE); + resetHideCallbacks(); + return; + } - private final Runnable hideProgressBar = - new Runnable() { - @Override - public void run() { - if (hideProgressBarAnimator == null) { - return; - } - hideProgressBarAnimator.start(); + switch (uxState) { + case UX_STATE_NONE_VISIBLE: + if (showAllBarsAnimator != null) { + showAllBarsAnimator.start(); } - }; + break; + case UX_STATE_ONLY_PROGRESS_VISIBLE: + if (showMainBarsAnimator != null) { + showMainBarsAnimator.start(); + } + break; + case UX_STATE_ANIMATING_HIDE: + needToShowBars = true; + break; + case UX_STATE_ANIMATING_SHOW: + return; + default: + break; + } + resetHideCallbacks(); + } - private final Runnable hideController = - new Runnable() { - @Override - public void run() { - setUxState(UX_STATE_NONE_VISIBLE); - } - }; + private void hideAllBars() { + if (hideAllBarsAnimator == null) { + return; + } + hideAllBarsAnimator.start(); + } + + private void hideProgressBar() { + if (hideProgressBarAnimator == null) { + return; + } + hideProgressBarAnimator.start(); + } + + private void hideMainBars() { + if (hideMainBarsAnimator == null) { + return; + } + hideMainBarsAnimator.start(); + postDelayedRunnable(hideProgressBarRunnable, ANIMATION_INTERVAL_MS); + } + + private void hideController() { + setUxState(UX_STATE_NONE_VISIBLE); + } private static ObjectAnimator ofTranslationY(float startValue, float endValue, View target) { return ObjectAnimator.ofFloat(target, "translationY", startValue, endValue); @@ -532,50 +558,6 @@ import java.util.ArrayList; } } - private final OnClickListener overflowListener = - new OnClickListener() { - @Override - public void onClick(View v) { - resetHideCallbacks(); - if (v.getId() == R.id.exo_overflow_show && overflowShowAnimator != null) { - overflowShowAnimator.start(); - } else if (v.getId() == R.id.exo_overflow_hide && overflowHideAnimator != null) { - overflowHideAnimator.start(); - } - } - }; - - @Override - public void onLayoutChange( - View v, - int left, - int top, - int right, - int bottom, - int oldLeft, - int oldTop, - int oldRight, - int oldBottom) { - - boolean shouldBeMinimalMode = shouldBeMinimalMode(); - if (isMinimalMode != shouldBeMinimalMode) { - isMinimalMode = shouldBeMinimalMode; - v.post(() -> updateLayoutForSizeChange()); - } - boolean widthChanged = (right - left) != (oldRight - oldLeft); - if (!isMinimalMode && widthChanged) { - v.post(() -> onLayoutWidthChanged()); - } - } - - private static int getWidth(@Nullable View v) { - return (v != null ? v.getWidth() : 0); - } - - private static int getHeight(@Nullable View v) { - return (v != null ? v.getHeight() : 0); - } - private boolean shouldBeMinimalMode() { if (this.styledPlayerControlView == null) { return isMinimalMode; @@ -733,4 +715,12 @@ import java.util.ArrayList; } } } + + private static int getWidth(@Nullable View v) { + return (v != null ? v.getWidth() : 0); + } + + private static int getHeight(@Nullable View v) { + return (v != null ? v.getHeight() : 0); + } } From aedf538aa87c6a7646e2f1a33aa07f196f06a538 Mon Sep 17 00:00:00 2001 From: William King Date: Thu, 23 Jul 2020 17:57:33 -0700 Subject: [PATCH 0651/1052] MKV Dolby Vision Support --- .../extractor/mkv/MatroskaExtractor.java | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index f566493ada..7fcb5c4f22 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.AvcConfig; import com.google.android.exoplayer2.video.ColorInfo; +import com.google.android.exoplayer2.video.DolbyVisionConfig; import com.google.android.exoplayer2.video.HevcConfig; import java.io.IOException; import java.lang.annotation.Documented; @@ -155,8 +156,13 @@ public class MatroskaExtractor implements Extractor { private static final int ID_BLOCK = 0xA1; private static final int ID_BLOCK_DURATION = 0x9B; private static final int ID_BLOCK_ADDITIONS = 0x75A1; + private static final int ID_BLOCK_ADDITION_MAPPING = 0x41E4; private static final int ID_BLOCK_MORE = 0xA6; private static final int ID_BLOCK_ADD_ID = 0xEE; + private static final int ID_BLOCK_ADD_ID_VALUE = 0x41F0; + private static final int ID_BLOCK_ADD_ID_NAME = 0x41A4; + private static final int ID_BLOCK_ADD_ID_TYPE = 0x41E7; + private static final int ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED; private static final int ID_BLOCK_ADDITIONAL = 0xA5; private static final int ID_REFERENCE_BLOCK = 0xFB; private static final int ID_TRACKS = 0x1654AE6B; @@ -231,6 +237,18 @@ public class MatroskaExtractor implements Extractor { */ private static final int BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4; + /** + * Dolby Vision configuration for profiles <= 7 + * https://www.matroska.org/technical/codec_specs.html + */ + private static final int BLOCK_ADDITIONAL_ID_DVCC = 0x64766343; + + /** + * Dolby Vision configuration for profiles > 7 + * https://www.matroska.org/technical/codec_specs.html + */ + private static final int BLOCK_ADDITIONAL_ID_DVVC = 0x64767643; + private static final int LACING_NONE = 0; private static final int LACING_XIPH = 1; private static final int LACING_FIXED_SIZE = 2; @@ -253,8 +271,8 @@ public class MatroskaExtractor implements Extractor { */ private static final byte[] SUBRIP_PREFIX = new byte[] { - 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, - 48, 58, 48, 48, 44, 48, 48, 48, 10 + 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, + 48, 58, 48, 48, 44, 48, 48, 48, 10 }; /** * The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. @@ -288,8 +306,8 @@ public class MatroskaExtractor implements Extractor { */ private static final byte[] SSA_PREFIX = new byte[] { - 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, - 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 + 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 }; /** * The byte offset of the end timecode in {@link #SSA_PREFIX}. @@ -510,6 +528,7 @@ public class MatroskaExtractor implements Extractor { case ID_CUE_TRACK_POSITIONS: case ID_BLOCK_GROUP: case ID_BLOCK_ADDITIONS: + case ID_BLOCK_ADDITION_MAPPING: case ID_BLOCK_MORE: case ID_PROJECTION: case ID_COLOUR: @@ -552,11 +571,14 @@ public class MatroskaExtractor implements Extractor { case ID_MAX_FALL: case ID_PROJECTION_TYPE: case ID_BLOCK_ADD_ID: + case ID_BLOCK_ADD_ID_VALUE: + case ID_BLOCK_ADD_ID_TYPE: return EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT; case ID_DOC_TYPE: case ID_NAME: case ID_CODEC_ID: case ID_LANGUAGE: + case ID_BLOCK_ADD_ID_NAME: return EbmlProcessor.ELEMENT_TYPE_STRING; case ID_SEEK_ID: case ID_CONTENT_COMPRESSION_SETTINGS: @@ -566,6 +588,7 @@ public class MatroskaExtractor implements Extractor { case ID_CODEC_PRIVATE: case ID_PROJECTION_PRIVATE: case ID_BLOCK_ADDITIONAL: + case ID_BLOCK_ADD_ID_EXTRA_DATA: return EbmlProcessor.ELEMENT_TYPE_BINARY; case ID_DURATION: case ID_SAMPLING_FREQUENCY: @@ -968,6 +991,9 @@ public class MatroskaExtractor implements Extractor { case ID_BLOCK_ADD_ID: blockAdditionalId = (int) value; break; + case ID_BLOCK_ADD_ID_TYPE: + currentTrack.blockAdditionalId = (int) value; + break; default: break; } @@ -1092,6 +1118,9 @@ public class MatroskaExtractor implements Extractor { currentTrack.cryptoData = new TrackOutput.CryptoData(C.CRYPTO_MODE_AES_CTR, encryptionKey, 0, 0); // We assume patternless AES-CTR. break; + case ID_BLOCK_ADD_ID_EXTRA_DATA: + handleBlockAddIDExtraData(currentTrack, input, contentSize); + break; case ID_SIMPLE_BLOCK: case ID_BLOCK: // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure @@ -1243,6 +1272,7 @@ public class MatroskaExtractor implements Extractor { protected void handleBlockAdditionalData( Track track, int blockAdditionalId, ExtractorInput input, int contentSize) throws IOException { + if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 && CODEC_ID_VP9.equals(track.codecId)) { blockAdditionalData.reset(contentSize); @@ -1253,6 +1283,19 @@ public class MatroskaExtractor implements Extractor { } } + protected void handleBlockAddIDExtraData( + Track track, ExtractorInput input, int contentSize) + throws IOException { + + if (track.blockAdditionalId == BLOCK_ADDITIONAL_ID_DVVC || track.blockAdditionalId == BLOCK_ADDITIONAL_ID_DVCC) { + track.doviDecoderConfigurationRecord = new byte[contentSize]; + input.readFully(track.doviDecoderConfigurationRecord, 0, contentSize); + } else { + // Unhandled block additional data. + input.skipFully(contentSize); + } + } + private void commitSampleToOutput( Track track, long timeUs, @C.BufferFlags int flags, int size, int offset) { if (track.trueHdSampleRechunker != null) { @@ -1915,6 +1958,8 @@ public class MatroskaExtractor implements Extractor { public float whitePointChromaticityY = Format.NO_VALUE; public float maxMasteringLuminance = Format.NO_VALUE; public float minMasteringLuminance = Format.NO_VALUE; + // DOVIDecoderConfigurationRecord structure as defined in [@!DolbyVisionWithinIso.2020-02] + @Nullable public byte[] doviDecoderConfigurationRecord = null; // Audio elements. Initially set to their default values. public int channelCount = 1; @@ -1933,6 +1978,9 @@ public class MatroskaExtractor implements Extractor { public TrackOutput output; public int nalUnitLengthFieldLength; + // Block additional state + private int blockAdditionalId; + /** Initializes the track with an output. */ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserException { String mimeType; @@ -2085,6 +2133,16 @@ public class MatroskaExtractor implements Extractor { throw new ParserException("Unrecognized codec identifier."); } + + if(doviDecoderConfigurationRecord != null) { + @Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig + .parse(new ParsableByteArray(doviDecoderConfigurationRecord)); + if (dolbyVisionConfig != null) { + codecs = dolbyVisionConfig.codecs; + mimeType = MimeTypes.VIDEO_DOLBY_VISION; + } + } + @C.SelectionFlags int selectionFlags = 0; selectionFlags |= flagDefault ? C.SELECTION_FLAG_DEFAULT : 0; selectionFlags |= flagForced ? C.SELECTION_FLAG_FORCED : 0; From 05f10cc7ed70c845fa29bbdaf5f198eb09c0e350 Mon Sep 17 00:00:00 2001 From: William King Date: Thu, 23 Jul 2020 18:13:16 -0700 Subject: [PATCH 0652/1052] fix tab --- .../android/exoplayer2/extractor/mkv/MatroskaExtractor.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 7fcb5c4f22..e7b074b0fd 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -271,8 +271,8 @@ public class MatroskaExtractor implements Extractor { */ private static final byte[] SUBRIP_PREFIX = new byte[] { - 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, - 48, 58, 48, 48, 44, 48, 48, 48, 10 + 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, + 48, 58, 48, 48, 44, 48, 48, 48, 10 }; /** * The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. From 3799cb34852dd7faf5005d946c48557ddc67a893 Mon Sep 17 00:00:00 2001 From: William King Date: Thu, 23 Jul 2020 18:13:41 -0700 Subject: [PATCH 0653/1052] fix tab 2 --- .../android/exoplayer2/extractor/mkv/MatroskaExtractor.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index e7b074b0fd..484f5e2528 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -306,8 +306,8 @@ public class MatroskaExtractor implements Extractor { */ private static final byte[] SSA_PREFIX = new byte[] { - 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, - 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 + 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 }; /** * The byte offset of the end timecode in {@link #SSA_PREFIX}. From 23d680a4b44656981bcf38b6454687bf0c5fc873 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 13 Jul 2020 17:29:58 +0100 Subject: [PATCH 0654/1052] Suppress deprecation warnings in deprecated places PiperOrigin-RevId: 320970814 --- .../android/exoplayer2/ext/cronet/CronetDataSource.java | 2 ++ .../google/android/exoplayer2/ext/media2/PlayerWrapper.java | 1 + .../android/exoplayer2/ext/okhttp/OkHttpDataSource.java | 1 + .../src/main/java/com/google/android/exoplayer2/C.java | 1 + .../src/main/java/com/google/android/exoplayer2/Format.java | 5 ++++- .../java/com/google/android/exoplayer2/audio/AudioSink.java | 1 + .../google/android/exoplayer2/audio/DefaultAudioSink.java | 1 + .../android/exoplayer2/source/SingleSampleMediaSource.java | 1 + .../android/exoplayer2/upstream/DefaultHttpDataSource.java | 1 + .../android/exoplayer2/upstream/cache/SimpleCache.java | 1 + .../android/exoplayer2/analytics/AnalyticsCollectorTest.java | 2 ++ .../exoplayer2/source/smoothstreaming/SsMediaSourceTest.java | 1 + .../android/exoplayer2/ui/PlayerNotificationManager.java | 4 ++++ 13 files changed, 21 insertions(+), 1 deletion(-) diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java index 0feff653dc..4d0333bb8c 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -243,6 +243,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public CronetDataSource( CronetEngine cronetEngine, @@ -276,6 +277,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean, * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public CronetDataSource( CronetEngine cronetEngine, diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java index 1794b8ccb5..dbf0f68f63 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java @@ -421,6 +421,7 @@ import java.util.List; listener.onShuffleModeChanged(Utils.getShuffleMode(shuffleModeEnabled)); } + @SuppressWarnings("deprecation") private void handlePlaybackParametersChanged(PlaybackParameters playbackParameters) { listener.onPlaybackSpeedChanged(playbackParameters.speed); } diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java index b529557aa2..6a7e48f7e2 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java @@ -119,6 +119,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { * @deprecated Use {@link #OkHttpDataSource(Call.Factory, String)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public OkHttpDataSource( Call.Factory callFactory, diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index 3ebbe8e743..bcb57c7d3c 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -564,6 +564,7 @@ public final class C { // ) /** @deprecated Use {@code Renderer.VideoScalingMode}. */ + @SuppressWarnings("deprecation") @Documented @Retention(RetentionPolicy.SOURCE) @IntDef(value = {VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Format.java b/library/common/src/main/java/com/google/android/exoplayer2/Format.java index c76219eeee..3027bce528 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Format.java @@ -1213,6 +1213,8 @@ public final class Format implements Parcelable { return new Builder().setId(id).setSampleMimeType(sampleMimeType).build(); } + // Some fields are deprecated but they're still assigned below. + @SuppressWarnings("deprecation") /* package */ Format( @Nullable String id, @Nullable String label, @@ -1298,7 +1300,8 @@ public final class Format implements Parcelable { this.exoMediaCryptoType = exoMediaCryptoType; } - @SuppressWarnings("ResourceType") + // Some fields are deprecated but they're still assigned below. + @SuppressWarnings({"ResourceType", "deprecation"}) /* package */ Format(Parcel in) { id = in.readString(); label = in.readString(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 2de8dcf8a6..38f693fced 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -302,6 +302,7 @@ public interface AudioSink { * @deprecated Use {@link #setPlaybackSpeed(float)} and {@link #setSkipSilenceEnabled(boolean)} * instead. */ + @SuppressWarnings("deprecation") @Deprecated void setPlaybackParameters(PlaybackParameters playbackParameters); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index bba93fe45a..2de6ee520c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -86,6 +86,7 @@ public final class DefaultAudioSink implements AudioSink { * @deprecated Use {@link #applyPlaybackSpeed(float)} and {@link * #applySkipSilenceEnabled(boolean)} instead. */ + @SuppressWarnings("deprecation") @Deprecated PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 815e4d95b1..e18e1738a2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -229,6 +229,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } /** @deprecated Use {@link Factory} instead. */ + @SuppressWarnings("deprecation") @Deprecated public SingleSampleMediaSource( Uri uri, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java index 08c89dd83a..7f01d2182e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java @@ -150,6 +150,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou * @deprecated Use {@link #DefaultHttpDataSource(String)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public DefaultHttpDataSource(String userAgent, @Nullable Predicate contentTypePredicate) { this( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index d7038c3a3d..17655fa312 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -134,6 +134,7 @@ public final class SimpleCache implements Cache { * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance. */ @Deprecated + @SuppressWarnings("deprecation") public SimpleCache(File cacheDir, CacheEvictor evictor) { this(cacheDir, evictor, null, false); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 9198303185..a33d16e4a5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -1603,6 +1603,7 @@ public final class AnalyticsCollectorTest { assertThat(reportedEvents).isEmpty(); } + @SuppressWarnings("deprecation") // Testing deprecated behaviour. @Override public void onPlayerStateChanged( EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { @@ -1626,6 +1627,7 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_SEEK_STARTED, eventTime)); } + @SuppressWarnings("deprecation") // Testing deprecated behaviour. @Override public void onSeekProcessed(EventTime eventTime) { reportedEvents.add(new ReportedEvent(EVENT_SEEK_PROCESSED, eventTime)); diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java index 39b1161af7..1f28d2263b 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java @@ -82,6 +82,7 @@ public class SsMediaSourceTest { } // Tests backwards compatibility + @SuppressWarnings("deprecation") @Test public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { Object tag = new Object(); diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java index b3d646c99d..b85bbc64b1 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -971,6 +971,8 @@ public class PlayerNotificationManager { } } + // We're calling a deprecated listener method that we still want to notify. + @SuppressWarnings("deprecation") private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) { boolean ongoing = getOngoing(player); builder = createNotification(player, builder, ongoing, bitmap); @@ -993,6 +995,8 @@ public class PlayerNotificationManager { } } + // We're calling a deprecated listener method that we still want to notify. + @SuppressWarnings("deprecation") private void stopNotification(boolean dismissedByUser) { if (isNotificationStarted) { isNotificationStarted = false; From 68cd283830ff775cadc77f2abebc561ddae7ed65 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 14 Jul 2020 08:12:53 +0100 Subject: [PATCH 0655/1052] Remove occurrences of grandfathering ISSUE: #7565 PiperOrigin-RevId: 321108417 --- .../com/google/android/exoplayer2/util/Util.java | 16 ++++++++-------- .../google/android/exoplayer2/util/UtilTest.java | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index cc43241757..afb97eb557 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -585,7 +585,7 @@ public final class Util { mainLanguage = replacedLanguage; } if ("no".equals(mainLanguage) || "i".equals(mainLanguage) || "zh".equals(mainLanguage)) { - normalizedTag = maybeReplaceGrandfatheredLanguageTags(normalizedTag); + normalizedTag = maybeReplaceLegacyLanguageTags(normalizedTag); } return normalizedTag; } @@ -2347,11 +2347,11 @@ public final class Util { .isCleartextTrafficPermitted(checkNotNull(uri.getHost())); } - private static String maybeReplaceGrandfatheredLanguageTags(String languageTag) { - for (int i = 0; i < isoGrandfatheredTagReplacements.length; i += 2) { - if (languageTag.startsWith(isoGrandfatheredTagReplacements[i])) { - return isoGrandfatheredTagReplacements[i + 1] - + languageTag.substring(/* beginIndex= */ isoGrandfatheredTagReplacements[i].length()); + private static String maybeReplaceLegacyLanguageTags(String languageTag) { + for (int i = 0; i < isoLegacyTagReplacements.length; i += 2) { + if (languageTag.startsWith(isoLegacyTagReplacements[i])) { + return isoLegacyTagReplacements[i + 1] + + languageTag.substring(/* beginIndex= */ isoLegacyTagReplacements[i].length()); } } return languageTag; @@ -2411,9 +2411,9 @@ public final class Util { "hsn", "zh-hsn" }; - // "Grandfathered tags", replaced by modern equivalents (including macrolanguage) + // Legacy ("grandfathered") tags, replaced by modern equivalents (including macrolanguage) // See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry. - private static final String[] isoGrandfatheredTagReplacements = + private static final String[] isoLegacyTagReplacements = new String[] { "i-lux", "lb", "i-hak", "zh-hak", diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java index de51274697..d3294997da 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java @@ -940,7 +940,7 @@ public class UtilTest { assertThat(Util.normalizeLanguageCode("ji")).isEqualTo(Util.normalizeLanguageCode("yi")); assertThat(Util.normalizeLanguageCode("ji")).isEqualTo(Util.normalizeLanguageCode("yid")); - // Grandfathered tags + // Legacy tags assertThat(Util.normalizeLanguageCode("i-lux")).isEqualTo(Util.normalizeLanguageCode("lb")); assertThat(Util.normalizeLanguageCode("i-lux")).isEqualTo(Util.normalizeLanguageCode("ltz")); assertThat(Util.normalizeLanguageCode("i-hak")).isEqualTo(Util.normalizeLanguageCode("hak")); From d615a3a7406e4861209fc76249503b1f14a9d4d7 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 14 Jul 2020 08:21:34 +0100 Subject: [PATCH 0656/1052] Fix sample time of partially fragmented MP4s with tfdt box PiperOrigin-RevId: 321109232 --- .../extractor/mp4/FragmentedMp4Extractor.java | 7 +- .../extractor/mp4/TrackFragment.java | 11 +- .../sample_partially_fragmented.mp4.0.dump | 140 +++++++++--------- ...rtially_fragmented.mp4.unknown_length.dump | 140 +++++++++--------- 4 files changed, 155 insertions(+), 143 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 0f420a52ef..822a9cee90 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -716,13 +716,16 @@ public class FragmentedMp4Extractor implements Extractor { TrackFragment fragment = trackBundle.fragment; long fragmentDecodeTime = fragment.nextFragmentDecodeTime; + boolean fragmentDecodeTimeIncludesMoov = fragment.nextFragmentDecodeTimeIncludesMoov; trackBundle.reset(); trackBundle.currentlyInFragment = true; @Nullable LeafAtom tfdtAtom = traf.getLeafAtomOfType(Atom.TYPE_tfdt); if (tfdtAtom != null && (flags & FLAG_WORKAROUND_IGNORE_TFDT_BOX) == 0) { fragment.nextFragmentDecodeTime = parseTfdt(traf.getLeafAtomOfType(Atom.TYPE_tfdt).data); + fragment.nextFragmentDecodeTimeIncludesMoov = true; } else { fragment.nextFragmentDecodeTime = fragmentDecodeTime; + fragment.nextFragmentDecodeTimeIncludesMoov = fragmentDecodeTimeIncludesMoov; } parseTruns(traf, trackBundle, flags); @@ -1023,7 +1026,9 @@ public class FragmentedMp4Extractor implements Extractor { } sampleDecodingTimeUsTable[i] = Util.scaleLargeTimestamp(cumulativeTime, C.MICROS_PER_SECOND, timescale) - edtsOffsetUs; - sampleDecodingTimeUsTable[i] += trackBundle.moovSampleTable.durationUs; + if (!fragment.nextFragmentDecodeTimeIncludesMoov) { + sampleDecodingTimeUsTable[i] += trackBundle.moovSampleTable.durationUs; + } sampleSizeTable[i] = sampleSize; sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0 && (!workaroundEveryVideoFrameIsSyncFrame || i == 0); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java index c60d0686a7..74f46d1837 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java @@ -89,10 +89,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public boolean sampleEncryptionDataNeedsFill; /** - * The duration of all samples defined in fragments up to and including this one. Samples defined - * in the moov box are not included. + * The duration of all the samples defined in the fragments up to and including this one, plus the + * duration of the samples defined in the moov atom if {@link #nextFragmentDecodeTimeIncludesMoov} + * is {@code true}. */ public long nextFragmentDecodeTime; + /** + * Whether {@link #nextFragmentDecodeTime} includes the duration of the samples referred to by the + * moov atom. + */ + public boolean nextFragmentDecodeTimeIncludesMoov; public TrackFragment() { trunDataPosition = new long[0]; @@ -115,6 +121,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public void reset() { trunCount = 0; nextFragmentDecodeTime = 0; + nextFragmentDecodeTimeIncludesMoov = false; definesEncryptionData = false; sampleEncryptionDataNeedsFill = false; trackEncryptionBox = null; diff --git a/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.0.dump b/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.0.dump index 7ee9341b9c..b3d57a3868 100644 --- a/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.0.dump +++ b/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.0.dump @@ -31,107 +31,107 @@ track 0: flags = 536870912 data = length 5867, hash 56F9EE87 sample 4: - time = 400398 + time = 166832 flags = 0 data = length 570, hash 984421BD sample 5: - time = 500499 + time = 266933 flags = 0 data = length 3406, hash 9A33201E sample 6: - time = 467132 + time = 233566 flags = 0 data = length 476, hash C59620F3 sample 7: - time = 567232 + time = 333666 flags = 0 data = length 4310, hash 291E6161 sample 8: - time = 533865 + time = 300299 flags = 0 data = length 497, hash 398CBFAA sample 9: - time = 700699 + time = 467133 flags = 0 data = length 4449, hash 322CAA2B sample 10: - time = 633965 + time = 400399 flags = 0 data = length 1076, hash B479B634 sample 11: - time = 600599 + time = 367033 flags = 0 data = length 365, hash 68C7D4C2 sample 12: - time = 667332 + time = 433766 flags = 0 data = length 463, hash A85F9769 sample 13: - time = 834165 + time = 600599 flags = 0 data = length 5339, hash F232195D sample 14: - time = 767432 + time = 533866 flags = 0 data = length 1085, hash 47AFB6FE sample 15: - time = 734066 + time = 500500 flags = 0 data = length 689, hash 3EB753A3 sample 16: - time = 800798 + time = 567232 flags = 0 data = length 516, hash E6DF9C1C sample 17: - time = 967632 + time = 734066 flags = 0 data = length 4899, hash A9A8F4B7 sample 18: - time = 900899 + time = 667333 flags = 0 data = length 963, hash 684782FB sample 19: - time = 867532 + time = 633966 flags = 0 data = length 625, hash ED1C8EF1 sample 20: - time = 934265 + time = 700699 flags = 0 data = length 492, hash E6E066EA sample 21: - time = 1101099 + time = 867533 flags = 0 data = length 2973, hash A3C54C3B sample 22: - time = 1034365 + time = 800799 flags = 0 data = length 833, hash 41CA807D sample 23: - time = 1000999 + time = 767433 flags = 0 data = length 516, hash 5B21BB11 sample 24: - time = 1067732 + time = 834166 flags = 0 data = length 384, hash A0E8FA50 sample 25: - time = 1234565 + time = 1000999 flags = 0 data = length 1450, hash 92741C3B sample 26: - time = 1167832 + time = 934266 flags = 0 data = length 831, hash DDA0685B sample 27: - time = 1134466 + time = 900900 flags = 0 data = length 413, hash 886904C sample 28: - time = 1201198 + time = 967632 flags = 0 data = length 427, hash FC2FA8CC sample 29: - time = 1267932 + time = 1034366 flags = 0 data = length 626, hash DCE82342 track 1: @@ -151,179 +151,179 @@ track 1: flags = 536870913 data = length 21, hash D57A2CCC sample 1: - time = 267890 + time = 133945 flags = 1 data = length 6, hash 336D5819 sample 2: - time = 291110 + time = 157165 flags = 1 data = length 279, hash 6E3E48B0 sample 3: - time = 314330 + time = 180385 flags = 1 data = length 286, hash 5AABFF sample 4: - time = 337550 + time = 203605 flags = 1 data = length 275, hash D3109764 sample 5: - time = 360770 + time = 226825 flags = 1 data = length 284, hash 154B6E9 sample 6: - time = 383990 + time = 250045 flags = 1 data = length 273, hash 40C8A066 sample 7: - time = 407210 + time = 273265 flags = 1 data = length 272, hash 4211880F sample 8: - time = 430430 + time = 296485 flags = 1 data = length 281, hash 1F534130 sample 9: - time = 453650 + time = 319705 flags = 1 data = length 279, hash F5B3EE5F sample 10: - time = 476870 + time = 342925 flags = 1 data = length 282, hash 6CDF1B54 sample 11: - time = 500090 + time = 366145 flags = 1 data = length 291, hash 6EC03046 sample 12: - time = 523310 + time = 389365 flags = 1 data = length 296, hash 9C7F2E6A sample 13: - time = 546530 + time = 412585 flags = 1 data = length 282, hash 584ABD5E sample 14: - time = 569749 + time = 435804 flags = 1 data = length 283, hash 38CB1734 sample 15: - time = 592969 + time = 459024 flags = 1 data = length 274, hash 648EC8BD sample 16: - time = 616189 + time = 482244 flags = 1 data = length 274, hash E8FE0F68 sample 17: - time = 639409 + time = 505464 flags = 1 data = length 277, hash 2E1B8A11 sample 18: - time = 662629 + time = 528684 flags = 1 data = length 282, hash FB6ACCED sample 19: - time = 685849 + time = 551904 flags = 1 data = length 283, hash 152D69D sample 20: - time = 709069 + time = 575124 flags = 1 data = length 274, hash 45F44C4B sample 21: - time = 732289 + time = 598344 flags = 1 data = length 242, hash F9225BB7 sample 22: - time = 755509 + time = 621564 flags = 1 data = length 207, hash F5DFB6B2 sample 23: - time = 778729 + time = 644784 flags = 1 data = length 226, hash 41DC63E1 sample 24: - time = 801949 + time = 668004 flags = 1 data = length 218, hash A82772CF sample 25: - time = 825169 + time = 691224 flags = 1 data = length 223, hash 861AB80 sample 26: - time = 848389 + time = 714444 flags = 1 data = length 220, hash F1CBA15E sample 27: - time = 871609 + time = 737664 flags = 1 data = length 203, hash CB57EEF7 sample 28: - time = 894829 + time = 760884 flags = 1 data = length 206, hash 766F4D9E sample 29: - time = 918049 + time = 784104 flags = 1 data = length 210, hash FE2A2935 sample 30: - time = 941269 + time = 807324 flags = 1 data = length 207, hash A06A178D sample 31: - time = 964489 + time = 830544 flags = 1 data = length 206, hash 1ABD9A5F sample 32: - time = 987709 + time = 853764 flags = 1 data = length 209, hash 69D7E5F3 sample 33: - time = 1010929 + time = 876984 flags = 1 data = length 173, hash 7CE0FDCA sample 34: - time = 1034149 + time = 900204 flags = 1 data = length 208, hash 21D67E09 sample 35: - time = 1057369 + time = 923424 flags = 1 data = length 207, hash C7187D46 sample 36: - time = 1080588 + time = 946643 flags = 1 data = length 180, hash D17CFAF8 sample 37: - time = 1103808 + time = 969863 flags = 1 data = length 206, hash C58FD669 sample 38: - time = 1127028 + time = 993083 flags = 1 data = length 212, hash 27E2F2C4 sample 39: - time = 1150248 + time = 1016303 flags = 1 data = length 190, hash 534CC89E sample 40: - time = 1173468 + time = 1039523 flags = 1 data = length 180, hash 1C58DF95 sample 41: - time = 1196688 + time = 1062743 flags = 1 data = length 213, hash 24FBF10A sample 42: - time = 1219908 + time = 1085963 flags = 1 data = length 186, hash EFC31805 sample 43: - time = 1243128 + time = 1109183 flags = 1 data = length 208, hash 4A050A0D sample 44: - time = 1266348 + time = 1132403 flags = 1 data = length 13, hash 2555A7DC tracksEnded = true diff --git a/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.unknown_length.dump b/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.unknown_length.dump index 7ee9341b9c..b3d57a3868 100644 --- a/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.unknown_length.dump +++ b/testdata/src/test/assets/mp4/sample_partially_fragmented.mp4.unknown_length.dump @@ -31,107 +31,107 @@ track 0: flags = 536870912 data = length 5867, hash 56F9EE87 sample 4: - time = 400398 + time = 166832 flags = 0 data = length 570, hash 984421BD sample 5: - time = 500499 + time = 266933 flags = 0 data = length 3406, hash 9A33201E sample 6: - time = 467132 + time = 233566 flags = 0 data = length 476, hash C59620F3 sample 7: - time = 567232 + time = 333666 flags = 0 data = length 4310, hash 291E6161 sample 8: - time = 533865 + time = 300299 flags = 0 data = length 497, hash 398CBFAA sample 9: - time = 700699 + time = 467133 flags = 0 data = length 4449, hash 322CAA2B sample 10: - time = 633965 + time = 400399 flags = 0 data = length 1076, hash B479B634 sample 11: - time = 600599 + time = 367033 flags = 0 data = length 365, hash 68C7D4C2 sample 12: - time = 667332 + time = 433766 flags = 0 data = length 463, hash A85F9769 sample 13: - time = 834165 + time = 600599 flags = 0 data = length 5339, hash F232195D sample 14: - time = 767432 + time = 533866 flags = 0 data = length 1085, hash 47AFB6FE sample 15: - time = 734066 + time = 500500 flags = 0 data = length 689, hash 3EB753A3 sample 16: - time = 800798 + time = 567232 flags = 0 data = length 516, hash E6DF9C1C sample 17: - time = 967632 + time = 734066 flags = 0 data = length 4899, hash A9A8F4B7 sample 18: - time = 900899 + time = 667333 flags = 0 data = length 963, hash 684782FB sample 19: - time = 867532 + time = 633966 flags = 0 data = length 625, hash ED1C8EF1 sample 20: - time = 934265 + time = 700699 flags = 0 data = length 492, hash E6E066EA sample 21: - time = 1101099 + time = 867533 flags = 0 data = length 2973, hash A3C54C3B sample 22: - time = 1034365 + time = 800799 flags = 0 data = length 833, hash 41CA807D sample 23: - time = 1000999 + time = 767433 flags = 0 data = length 516, hash 5B21BB11 sample 24: - time = 1067732 + time = 834166 flags = 0 data = length 384, hash A0E8FA50 sample 25: - time = 1234565 + time = 1000999 flags = 0 data = length 1450, hash 92741C3B sample 26: - time = 1167832 + time = 934266 flags = 0 data = length 831, hash DDA0685B sample 27: - time = 1134466 + time = 900900 flags = 0 data = length 413, hash 886904C sample 28: - time = 1201198 + time = 967632 flags = 0 data = length 427, hash FC2FA8CC sample 29: - time = 1267932 + time = 1034366 flags = 0 data = length 626, hash DCE82342 track 1: @@ -151,179 +151,179 @@ track 1: flags = 536870913 data = length 21, hash D57A2CCC sample 1: - time = 267890 + time = 133945 flags = 1 data = length 6, hash 336D5819 sample 2: - time = 291110 + time = 157165 flags = 1 data = length 279, hash 6E3E48B0 sample 3: - time = 314330 + time = 180385 flags = 1 data = length 286, hash 5AABFF sample 4: - time = 337550 + time = 203605 flags = 1 data = length 275, hash D3109764 sample 5: - time = 360770 + time = 226825 flags = 1 data = length 284, hash 154B6E9 sample 6: - time = 383990 + time = 250045 flags = 1 data = length 273, hash 40C8A066 sample 7: - time = 407210 + time = 273265 flags = 1 data = length 272, hash 4211880F sample 8: - time = 430430 + time = 296485 flags = 1 data = length 281, hash 1F534130 sample 9: - time = 453650 + time = 319705 flags = 1 data = length 279, hash F5B3EE5F sample 10: - time = 476870 + time = 342925 flags = 1 data = length 282, hash 6CDF1B54 sample 11: - time = 500090 + time = 366145 flags = 1 data = length 291, hash 6EC03046 sample 12: - time = 523310 + time = 389365 flags = 1 data = length 296, hash 9C7F2E6A sample 13: - time = 546530 + time = 412585 flags = 1 data = length 282, hash 584ABD5E sample 14: - time = 569749 + time = 435804 flags = 1 data = length 283, hash 38CB1734 sample 15: - time = 592969 + time = 459024 flags = 1 data = length 274, hash 648EC8BD sample 16: - time = 616189 + time = 482244 flags = 1 data = length 274, hash E8FE0F68 sample 17: - time = 639409 + time = 505464 flags = 1 data = length 277, hash 2E1B8A11 sample 18: - time = 662629 + time = 528684 flags = 1 data = length 282, hash FB6ACCED sample 19: - time = 685849 + time = 551904 flags = 1 data = length 283, hash 152D69D sample 20: - time = 709069 + time = 575124 flags = 1 data = length 274, hash 45F44C4B sample 21: - time = 732289 + time = 598344 flags = 1 data = length 242, hash F9225BB7 sample 22: - time = 755509 + time = 621564 flags = 1 data = length 207, hash F5DFB6B2 sample 23: - time = 778729 + time = 644784 flags = 1 data = length 226, hash 41DC63E1 sample 24: - time = 801949 + time = 668004 flags = 1 data = length 218, hash A82772CF sample 25: - time = 825169 + time = 691224 flags = 1 data = length 223, hash 861AB80 sample 26: - time = 848389 + time = 714444 flags = 1 data = length 220, hash F1CBA15E sample 27: - time = 871609 + time = 737664 flags = 1 data = length 203, hash CB57EEF7 sample 28: - time = 894829 + time = 760884 flags = 1 data = length 206, hash 766F4D9E sample 29: - time = 918049 + time = 784104 flags = 1 data = length 210, hash FE2A2935 sample 30: - time = 941269 + time = 807324 flags = 1 data = length 207, hash A06A178D sample 31: - time = 964489 + time = 830544 flags = 1 data = length 206, hash 1ABD9A5F sample 32: - time = 987709 + time = 853764 flags = 1 data = length 209, hash 69D7E5F3 sample 33: - time = 1010929 + time = 876984 flags = 1 data = length 173, hash 7CE0FDCA sample 34: - time = 1034149 + time = 900204 flags = 1 data = length 208, hash 21D67E09 sample 35: - time = 1057369 + time = 923424 flags = 1 data = length 207, hash C7187D46 sample 36: - time = 1080588 + time = 946643 flags = 1 data = length 180, hash D17CFAF8 sample 37: - time = 1103808 + time = 969863 flags = 1 data = length 206, hash C58FD669 sample 38: - time = 1127028 + time = 993083 flags = 1 data = length 212, hash 27E2F2C4 sample 39: - time = 1150248 + time = 1016303 flags = 1 data = length 190, hash 534CC89E sample 40: - time = 1173468 + time = 1039523 flags = 1 data = length 180, hash 1C58DF95 sample 41: - time = 1196688 + time = 1062743 flags = 1 data = length 213, hash 24FBF10A sample 42: - time = 1219908 + time = 1085963 flags = 1 data = length 186, hash EFC31805 sample 43: - time = 1243128 + time = 1109183 flags = 1 data = length 208, hash 4A050A0D sample 44: - time = 1266348 + time = 1132403 flags = 1 data = length 13, hash 2555A7DC tracksEnded = true From e9a8335381cf82f8daacbb9aaa3e7283c9058306 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 10:20:33 +0100 Subject: [PATCH 0657/1052] Migrate callers to pass MediaItem to createMediaSource() createMediaSource(Uri) is deprecated. PiperOrigin-RevId: 321121383 --- .../android/exoplayer2/gldemo/MainActivity.java | 5 +++-- .../exoplayer2/surfacedemo/MainActivity.java | 5 +++-- .../exoplayer2/ext/flac/FlacPlaybackTest.java | 3 ++- .../android/exoplayer2/ext/media2/Utils.java | 14 ++++++++++---- .../exoplayer2/ext/opus/OpusPlaybackTest.java | 3 ++- .../exoplayer2/ext/vp9/VpxPlaybackTest.java | 3 ++- .../exoplayer2/source/ExtractorMediaSource.java | 2 +- .../exoplayer2/source/ads/AdsMediaSource.java | 3 ++- .../exoplayer2/source/ads/AdsMediaSourceTest.java | 3 ++- .../exoplayer2/source/dash/DashMediaSource.java | 2 +- .../exoplayer2/source/hls/HlsMediaSource.java | 2 +- .../source/smoothstreaming/SsMediaSource.java | 2 +- 12 files changed, 30 insertions(+), 17 deletions(-) diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java index c788f752f7..6944eb662d 100644 --- a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java @@ -24,6 +24,7 @@ import android.widget.FrameLayout; import android.widget.Toast; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; @@ -164,12 +165,12 @@ public final class MainActivity extends Activity { mediaSource = new DashMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else if (type == C.TYPE_OTHER) { mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else { throw new IllegalStateException(); } diff --git a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java index 67419edf3b..1cd5c128c1 100644 --- a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java +++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java @@ -28,6 +28,7 @@ import android.widget.Button; import android.widget.GridLayout; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; @@ -209,12 +210,12 @@ public final class MainActivity extends Activity { mediaSource = new DashMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else if (type == C.TYPE_OTHER) { mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else { throw new IllegalStateException(); } diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java index e9b1fd1019..ccd0da4e19 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -25,6 +25,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioSink; @@ -109,7 +110,7 @@ public class FlacPlaybackTest { new ProgressiveMediaSource.Factory( new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"), MatroskaExtractor.FACTORY) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); player.setMediaSource(mediaSource); player.prepare(); player.play(); diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java index d3e90a1c34..cd86bc18dd 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java @@ -80,8 +80,11 @@ import com.google.android.exoplayer2.util.Util; dataSourceFactory = DataSourceCallbackDataSource.getFactory(callbackMediaItem.getDataSourceCallback()); return new ProgressiveMediaSource.Factory(dataSourceFactory, sExtractorsFactory) - .setTag(mediaItem) - .createMediaSource(Uri.EMPTY); + .createMediaSource( + new com.google.android.exoplayer2.MediaItem.Builder() + .setUri(Uri.EMPTY) + .setTag(mediaItem) + .build()); } else { throw new IllegalStateException(); } @@ -185,13 +188,16 @@ import com.google.android.exoplayer2.util.Util; MediaSourceFactory mediaSourceFactory = factoryClazz.getConstructor(DataSource.Factory.class).newInstance(dataSourceFactory); factoryClazz.getMethod("setTag", Object.class).invoke(mediaSourceFactory, tag); - return mediaSourceFactory.createMediaSource(uri); + return mediaSourceFactory.createMediaSource( + com.google.android.exoplayer2.MediaItem.fromUri(uri)); } // LINT.ThenChange(../../../../../../../../../proguard-rules.txt) } catch (Exception e) { // Expected if the app was built without the corresponding module. } - return new ProgressiveMediaSource.Factory(dataSourceFactory).setTag(tag).createMediaSource(uri); + return new ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource( + new com.google.android.exoplayer2.MediaItem.Builder().setUri(uri).setTag(tag).build()); } private Utils() { diff --git a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java index e4e392f2d3..73e4941cf9 100644 --- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java +++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java @@ -25,6 +25,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; @@ -93,7 +94,7 @@ public class OpusPlaybackTest { new ProgressiveMediaSource.Factory( new DefaultDataSourceFactory(context, "ExoPlayerExtOpusTest"), MatroskaExtractor.FACTORY) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); player.prepare(mediaSource); player.play(); Looper.loop(); diff --git a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java index 7b81c0b9b8..b16592fdc6 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -27,6 +27,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; @@ -121,7 +122,7 @@ public class VpxPlaybackTest { new ProgressiveMediaSource.Factory( new DefaultDataSourceFactory(context, "ExoPlayerExtVp9Test"), MatroskaExtractor.FACTORY) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); player .createMessage(videoRenderer) .setType(C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java index c55b424520..1e8129bf3a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java @@ -197,7 +197,7 @@ public final class ExtractorMediaSource extends CompositeMediaSource { } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ @Deprecated diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index f805642da8..d4cb455628 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -224,7 +224,8 @@ public final class AdsMediaSource extends CompositeMediaSource { AdMediaSourceHolder adMediaSourceHolder = adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]; if (adMediaSourceHolder == null) { - MediaSource adMediaSource = adMediaSourceFactory.createMediaSource(adUri); + MediaSource adMediaSource = + adMediaSourceFactory.createMediaSource(MediaItem.fromUri(adUri)); adMediaSourceHolder = new AdMediaSourceHolder(adMediaSource); adMediaSourceHolders[adGroupIndex][adIndexInAdGroup] = adMediaSourceHolder; prepareChildSource(id, adMediaSource); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java index ce0603aaef..b0c7180d87 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -100,7 +100,8 @@ public final class AdsMediaSourceTest { contentMediaSource = new FakeMediaSource(/* timeline= */ null); prerollAdMediaSource = new FakeMediaSource(/* timeline= */ null); MediaSourceFactory adMediaSourceFactory = mock(MediaSourceFactory.class); - when(adMediaSourceFactory.createMediaSource(any(Uri.class))).thenReturn(prerollAdMediaSource); + when(adMediaSourceFactory.createMediaSource(any(MediaItem.class))) + .thenReturn(prerollAdMediaSource); // Prepare the AdsMediaSource and capture its ads loader listener. AdsLoader mockAdsLoader = mock(AdsLoader.class); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 1a6ea0e763..7583462ad7 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -333,7 +333,7 @@ public final class DashMediaSource extends BaseMediaSource { } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ @SuppressWarnings("deprecation") diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index b361a5a1d6..735d61cb82 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -313,7 +313,7 @@ public final class HlsMediaSource extends BaseMediaSource } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ @SuppressWarnings("deprecation") diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 9ffc483117..35a3c0a899 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -301,7 +301,7 @@ public final class SsMediaSource extends BaseMediaSource } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ @SuppressWarnings("deprecation") From b48a762f2091dfd198fbf22c4db05368b00e2e6d Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 10:24:05 +0100 Subject: [PATCH 0658/1052] Migrate overrides of deprecated AdsViewProvider.getAdOverlayViews PiperOrigin-RevId: 321121735 --- extensions/ima/build.gradle | 1 + .../android/exoplayer2/ext/ima/ImaPlaybackTest.java | 8 +++++--- .../android/exoplayer2/ext/ima/ImaAdsLoaderTest.java | 8 ++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index 33985b2639..faae0b3679 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -35,6 +35,7 @@ dependencies { androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion + androidTestImplementation 'com.google.guava:guava:' + guavaVersion androidTestCompileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'com.google.guava:guava:' + guavaVersion diff --git a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java index 0e685e55ea..31cd29de94 100644 --- a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java +++ b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java @@ -20,7 +20,6 @@ import static com.google.common.truth.Truth.assertThat; import android.content.Context; import android.net.Uri; import android.view.Surface; -import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import androidx.annotation.Nullable; @@ -39,6 +38,7 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.testutil.ActionSchedule; @@ -50,6 +50,7 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -249,14 +250,15 @@ public final class ImaPlaybackTest { dataSourceFactory, Assertions.checkNotNull(imaAdsLoader), new AdViewProvider() { + @Override public ViewGroup getAdViewGroup() { return overlayFrameLayout; } @Override - public View[] getAdOverlayViews() { - return new View[0]; + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of(); } }); } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index a9acbad72c..23b7110103 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -116,7 +116,6 @@ public final class ImaAdsLoaderTest { @Mock private AdEvent mockPostrollFetchErrorAdEvent; private ViewGroup adViewGroup; - private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; private AdEvent.AdEventListener adEventListener; private ContentProgressProvider contentProgressProvider; @@ -129,7 +128,7 @@ public final class ImaAdsLoaderTest { public void setUp() { setupMocks(); adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext()); - adOverlayView = new View(ApplicationProvider.getApplicationContext()); + View adOverlayView = new View(ApplicationProvider.getApplicationContext()); adViewProvider = new AdsLoader.AdViewProvider() { @Override @@ -138,8 +137,9 @@ public final class ImaAdsLoaderTest { } @Override - public View[] getAdOverlayViews() { - return new View[] {adOverlayView}; + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of( + new AdsLoader.OverlayInfo(adOverlayView, AdsLoader.OverlayInfo.PURPOSE_CLOSE_AD)); } }; } From ff4516086cebdada482cf52e2794354e33c17d5b Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 10:27:05 +0100 Subject: [PATCH 0659/1052] Migrate usages of deprecated InvalidResponseCodeException constructor PiperOrigin-RevId: 321121988 --- .../DefaultLoadErrorHandlingPolicyTest.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java index 4adeab560a..50b06c14db 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.Collections; import org.junit.Test; @@ -50,7 +51,11 @@ public final class DefaultLoadErrorHandlingPolicyTest { public void getExclusionDurationMsFor_responseCode404() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 404, "Not Found", Collections.emptyMap(), new DataSpec(Uri.EMPTY)); + 404, + "Not Found", + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); } @@ -59,7 +64,11 @@ public final class DefaultLoadErrorHandlingPolicyTest { public void getExclusionDurationMsFor_responseCode410() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 410, "Gone", Collections.emptyMap(), new DataSpec(Uri.EMPTY)); + 410, + "Gone", + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); } @@ -68,7 +77,11 @@ public final class DefaultLoadErrorHandlingPolicyTest { public void getExclusionDurationMsFor_dontExcludeUnexpectedHttpCodes() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 500, "Internal Server Error", Collections.emptyMap(), new DataSpec(Uri.EMPTY)); + 500, + "Internal Server Error", + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); assertThat(getDefaultPolicyExclusionDurationMsFor(exception)).isEqualTo(C.TIME_UNSET); } From 425d48b67a00504741952494d5f53732f12e8fba Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 10:32:02 +0100 Subject: [PATCH 0660/1052] Migrate usages of Cue constructors to Cue.Builder All the public Cue constructors are deprecated PiperOrigin-RevId: 321122512 --- .../google/android/exoplayer2/text/Cue.java | 5 +++- .../exoplayer2/text/cea/Cea608Decoder.java | 17 +++++------ .../exoplayer2/text/cea/Cea708Decoder.java | 25 ++++++++-------- .../exoplayer2/text/ssa/SsaDecoder.java | 28 +++++++----------- .../exoplayer2/text/subrip/SubripDecoder.java | 29 +++++++------------ .../exoplayer2/text/tx3g/Tx3gDecoder.java | 17 +++++------ 6 files changed, 54 insertions(+), 67 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java index 98ce0dfc93..268133ad40 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java @@ -34,7 +34,7 @@ import java.lang.annotation.RetentionPolicy; public final class Cue { /** The empty cue. */ - public static final Cue EMPTY = new Cue(""); + public static final Cue EMPTY = new Cue.Builder().setText("").build(); /** An unset position, width or size. */ // Note: We deliberately don't use Float.MIN_VALUE because it's positive & very close to zero. @@ -276,6 +276,7 @@ public final class Cue { * @param text See {@link #text}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue(CharSequence text) { this( @@ -302,6 +303,7 @@ public final class Cue { * @param size See {@link #size}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue( CharSequence text, @@ -340,6 +342,7 @@ public final class Cue { * @param textSize See {@link #textSize}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue( CharSequence text, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 14b5be0504..35dd290fb3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -906,7 +906,6 @@ public final class Cea608Decoder extends CeaDecoder { } int positionAnchor; - // The number of empty columns after the end of the text, in the same range. int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); int startEndPaddingDelta = startPadding - endPadding; @@ -960,15 +959,13 @@ public final class Cea608Decoder extends CeaDecoder { line = captionMode == CC_MODE_ROLL_UP ? row - (captionRowCount - 1) : row; } - return new Cue( - cueString, - Alignment.ALIGN_NORMAL, - line, - Cue.LINE_TYPE_NUMBER, - /* lineAnchor= */ Cue.TYPE_UNSET, - position, - positionAnchor, - Cue.DIMEN_UNSET); + return new Cue.Builder() + .setText(cueString) + .setTextAlignment(Alignment.ALIGN_NORMAL) + .setLine(line, Cue.LINE_TYPE_NUMBER) + .setPosition(position) + .setPositionAnchor(positionAnchor) + .build(); } private SpannableString buildCurrentLine() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java index 182fe7a2fe..8bd46fabdc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java @@ -1329,18 +1329,19 @@ public final class Cea708Decoder extends CeaDecoder { boolean windowColorSet, int windowColor, int priority) { - this.cue = - new Cue( - text, - textAlignment, - line, - lineType, - lineAnchor, - position, - positionAnchor, - size, - windowColorSet, - windowColor); + Cue.Builder cueBuilder = + new Cue.Builder() + .setText(text) + .setTextAlignment(textAlignment) + .setLine(line, lineType) + .setLineAnchor(lineAnchor) + .setPosition(position) + .setPositionAnchor(positionAnchor) + .setSize(size); + if (windowColorSet) { + cueBuilder.setWindowColor(windowColor); + } + this.cue = cueBuilder.build(); this.priority = priority; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java index b963b60479..3bb39aba9c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.text.ssa; +import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_FRACTION; import static com.google.android.exoplayer2.util.Util.castNonNull; import android.text.Layout; @@ -299,6 +300,8 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { SsaStyle.Overrides styleOverrides, float screenWidth, float screenHeight) { + Cue.Builder cue = new Cue.Builder().setText(text); + @SsaStyle.SsaAlignment int alignment; if (styleOverrides.alignment != SsaStyle.SSA_ALIGNMENT_UNKNOWN) { alignment = styleOverrides.alignment; @@ -307,31 +310,22 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { } else { alignment = SsaStyle.SSA_ALIGNMENT_UNKNOWN; } - @Cue.AnchorType int positionAnchor = toPositionAnchor(alignment); - @Cue.AnchorType int lineAnchor = toLineAnchor(alignment); + cue.setTextAlignment(toTextAlignment(alignment)) + .setPositionAnchor(toPositionAnchor(alignment)) + .setLineAnchor(toLineAnchor(alignment)); - float position; - float line; if (styleOverrides.position != null && screenHeight != Cue.DIMEN_UNSET && screenWidth != Cue.DIMEN_UNSET) { - position = styleOverrides.position.x / screenWidth; - line = styleOverrides.position.y / screenHeight; + cue.setPosition(styleOverrides.position.x / screenWidth); + cue.setLine(styleOverrides.position.y / screenHeight, LINE_TYPE_FRACTION); } else { // TODO: Read the MarginL, MarginR and MarginV values from the Style & Dialogue lines. - position = computeDefaultLineOrPosition(positionAnchor); - line = computeDefaultLineOrPosition(lineAnchor); + cue.setPosition(computeDefaultLineOrPosition(cue.getPositionAnchor())); + cue.setLine(computeDefaultLineOrPosition(cue.getLineAnchor()), LINE_TYPE_FRACTION); } - return new Cue( - text, - toTextAlignment(alignment), - line, - Cue.LINE_TYPE_FRACTION, - lineAnchor, - position, - positionAnchor, - /* size= */ Cue.DIMEN_UNSET); + return cue.build(); } @Nullable diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java index 6e94b9782a..efbf3ab64f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java @@ -173,61 +173,54 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { * @return Built cue */ private Cue buildCue(Spanned text, @Nullable String alignmentTag) { + Cue.Builder cue = new Cue.Builder().setText(text); if (alignmentTag == null) { - return new Cue(text); + return cue.build(); } // Horizontal alignment. - @Cue.AnchorType int positionAnchor; switch (alignmentTag) { case ALIGN_BOTTOM_LEFT: case ALIGN_MID_LEFT: case ALIGN_TOP_LEFT: - positionAnchor = Cue.ANCHOR_TYPE_START; + cue.setPositionAnchor(Cue.ANCHOR_TYPE_START); break; case ALIGN_BOTTOM_RIGHT: case ALIGN_MID_RIGHT: case ALIGN_TOP_RIGHT: - positionAnchor = Cue.ANCHOR_TYPE_END; + cue.setPositionAnchor(Cue.ANCHOR_TYPE_END); break; case ALIGN_BOTTOM_MID: case ALIGN_MID_MID: case ALIGN_TOP_MID: default: - positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; + cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE); break; } // Vertical alignment. - @Cue.AnchorType int lineAnchor; switch (alignmentTag) { case ALIGN_BOTTOM_LEFT: case ALIGN_BOTTOM_MID: case ALIGN_BOTTOM_RIGHT: - lineAnchor = Cue.ANCHOR_TYPE_END; + cue.setLineAnchor(Cue.ANCHOR_TYPE_END); break; case ALIGN_TOP_LEFT: case ALIGN_TOP_MID: case ALIGN_TOP_RIGHT: - lineAnchor = Cue.ANCHOR_TYPE_START; + cue.setLineAnchor(Cue.ANCHOR_TYPE_START); break; case ALIGN_MID_LEFT: case ALIGN_MID_MID: case ALIGN_MID_RIGHT: default: - lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; + cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE); break; } - return new Cue( - text, - /* textAlignment= */ null, - getFractionalPositionForAnchorType(lineAnchor), - Cue.LINE_TYPE_FRACTION, - lineAnchor, - getFractionalPositionForAnchorType(positionAnchor), - positionAnchor, - Cue.DIMEN_UNSET); + return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) + .setLine(getFractionalPositionForAnchorType(cue.getLineAnchor()), Cue.LINE_TYPE_FRACTION) + .build(); } private static long parseTimecode(Matcher matcher, int groupOffset) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java index c290ec4c86..4ce0ea8df5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.text.tx3g; +import static com.google.android.exoplayer2.text.Cue.ANCHOR_TYPE_START; +import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_FRACTION; + import android.graphics.Color; import android.graphics.Typeface; import android.text.SpannableStringBuilder; @@ -150,15 +153,11 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { parsableByteArray.setPosition(position + atomSize); } return new Tx3gSubtitle( - new Cue( - cueText, - /* textAlignment= */ null, - verticalPlacement, - Cue.LINE_TYPE_FRACTION, - Cue.ANCHOR_TYPE_START, - Cue.DIMEN_UNSET, - Cue.TYPE_UNSET, - Cue.DIMEN_UNSET)); + new Cue.Builder() + .setText(cueText) + .setLine(verticalPlacement, LINE_TYPE_FRACTION) + .setLineAnchor(ANCHOR_TYPE_START) + .build()); } private static String readSubtitleText(ParsableByteArray parsableByteArray) From f83d478cc3a6179a26fa843a5c5cad73032a0a56 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 14:39:59 +0100 Subject: [PATCH 0661/1052] Migrate uses of prepare(MediaSource) to setMediaSource() & prepare() PiperOrigin-RevId: 321147910 --- .../ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java | 3 ++- .../google/android/exoplayer2/ext/opus/OpusPlaybackTest.java | 3 ++- .../com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java | 3 ++- .../src/main/java/com/google/android/exoplayer2/ExoPlayer.java | 2 +- .../test/java/com/google/android/exoplayer2/ExoPlayerTest.java | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java index 6731fad4c0..bb1129dc83 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java @@ -42,6 +42,7 @@ public final class ConcatenatingMediaSourcePlaybackPreparer implements PlaybackP @Override public void preparePlayback() { - exoPlayer.prepare(concatenatingMediaSource); + exoPlayer.setMediaSource(concatenatingMediaSource); + exoPlayer.prepare(); } } diff --git a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java index 73e4941cf9..26f013d572 100644 --- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java +++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java @@ -95,7 +95,8 @@ public class OpusPlaybackTest { new DefaultDataSourceFactory(context, "ExoPlayerExtOpusTest"), MatroskaExtractor.FACTORY) .createMediaSource(MediaItem.fromUri(uri)); - player.prepare(mediaSource); + player.setMediaSource(mediaSource); + player.prepare(); player.play(); Looper.loop(); } diff --git a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java index b16592fdc6..210a5bbc8a 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -128,7 +128,8 @@ public class VpxPlaybackTest { .setType(C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) .setPayload(new VideoDecoderGLSurfaceView(context).getVideoDecoderOutputBufferRenderer()) .send(); - player.prepare(mediaSource); + player.setMediaSource(mediaSource); + player.prepare(); player.play(); Looper.loop(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 736ed9f708..85d40095ac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -61,7 +61,7 @@ import java.util.List; *

          *
        • A {@link MediaSource} that defines the media to be played, loads the media, and from * which the loaded media can be read. A MediaSource is injected via {@link - * #prepare(MediaSource)} at the start of playback. The library modules provide default + * #setMediaSource(MediaSource)} at the start of playback. The library modules provide default * implementations for progressive media files ({@link ProgressiveMediaSource}), DASH * (DashMediaSource), SmoothStreaming (SsMediaSource) and HLS (HlsMediaSource), an * implementation for loading single media samples ({@link SingleSampleMediaSource}) that's diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 6466babe66..6959c5fe4b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -3526,6 +3526,7 @@ public final class ExoPlayerTest { assertArrayEquals(new long[] {5_000}, currentPlaybackPositions); } + @SuppressWarnings("deprecation") @Test public void seekTo_windowIndexIsReset_deprecated() throws Exception { FakeTimeline fakeTimeline = new FakeTimeline(/* windowCount= */ 1); @@ -3542,7 +3543,6 @@ public final class ExoPlayerTest { new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - //noinspection deprecation player.prepare(mediaSource); player.seekTo(/* positionMs= */ 5000); } From f02404563878013171a34cb10f9360c55fd2d6fd Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 14:43:12 +0100 Subject: [PATCH 0662/1052] Migrate uses of Uri-based ProgressiveDownloader() to MediaItem-based The constructor that takes a Uri is deprecated PiperOrigin-RevId: 321148326 --- .../exoplayer2/offline/DefaultDownloaderFactory.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java index f7b12a349e..2226e8eeb5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.util.Assertions; import java.lang.reflect.Constructor; @@ -107,7 +108,12 @@ public class DefaultDownloaderFactory implements DownloaderFactory { switch (request.type) { case DownloadRequest.TYPE_PROGRESSIVE: return new ProgressiveDownloader( - request.uri, request.customCacheKey, cacheDataSourceFactory, executor); + new MediaItem.Builder() + .setUri(request.uri) + .setCustomCacheKey(request.customCacheKey) + .build(), + cacheDataSourceFactory, + executor); case DownloadRequest.TYPE_DASH: return createDownloader(request, DASH_DOWNLOADER_CONSTRUCTOR); case DownloadRequest.TYPE_HLS: From e9482c7f0cb5257207dbe6f30c88c153435386ad Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 15:26:20 +0100 Subject: [PATCH 0663/1052] Migrate uses of deprecated DefaultDownloadFactory constructor PiperOrigin-RevId: 321153963 --- .../android/exoplayer2/offline/DefaultDownloaderFactory.java | 2 +- .../exoplayer2/offline/DefaultDownloaderFactoryTest.java | 3 ++- .../exoplayer2/source/dash/offline/DashDownloaderTest.java | 3 ++- .../source/dash/offline/DownloadManagerDashTest.java | 3 ++- .../source/dash/offline/DownloadServiceDashTest.java | 3 ++- .../exoplayer2/source/hls/offline/HlsDownloaderTest.java | 3 ++- .../source/smoothstreaming/offline/SsDownloaderTest.java | 3 ++- 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java index 2226e8eeb5..183d214759 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -84,7 +84,7 @@ public class DefaultDownloaderFactory implements DownloaderFactory { */ @Deprecated public DefaultDownloaderFactory(CacheDataSource.Factory cacheDataSourceFactory) { - this(cacheDataSourceFactory, Runnable::run); + this(cacheDataSourceFactory, /* executor= */ Runnable::run); } /** diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java index 5955a9491e..bf762f0da9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java @@ -37,7 +37,8 @@ public final class DefaultDownloaderFactoryTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java index 7786a00758..23fb9cbe3d 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java @@ -85,7 +85,8 @@ public class DashDownloaderTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index e7c2630da0..76b7a76a66 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -241,7 +241,8 @@ public class DownloadManagerDashTest { new DefaultDownloaderFactory( new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(fakeDataSourceFactory)); + .setUpstreamDataSourceFactory(fakeDataSourceFactory), + /* executor= */ Runnable::run); downloadManager = new DownloadManager( ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index 7911ef22fc..9a3e5a4aff 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -117,7 +117,8 @@ public class DownloadServiceDashTest { new DefaultDownloaderFactory( new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(fakeDataSourceFactory)); + .setUpstreamDataSourceFactory(fakeDataSourceFactory), + /* executor= */ Runnable::run); final DownloadManager dashDownloadManager = new DownloadManager( ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java index 225d57ea5a..7b5577d22d 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java @@ -102,7 +102,8 @@ public class HlsDownloaderTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java index 1bbe0b191d..1e6ea37365 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java @@ -42,7 +42,8 @@ public final class SsDownloaderTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( From 18d8db4e7711fdb8df09d30dcbc2a63d91f0e672 Mon Sep 17 00:00:00 2001 From: gyumin Date: Tue, 14 Jul 2020 15:34:06 +0100 Subject: [PATCH 0664/1052] Move DeviceInfo from core to common In order to use DeviceInfo class in media2, this CL moves the class to common module. It didn't move the other file in the same package, DeviceListener, as it's for DeviceComponent but media2 SessionPlayer doesn't have components as it is already flattened. PlayerCallback will have equivalent methods of DeviceListener. PiperOrigin-RevId: 321154997 --- .../java/com/google/android/exoplayer2/device/DeviceInfo.java | 0 .../java/com/google/android/exoplayer2/device/package-info.java | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename library/{core => common}/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java (100%) rename library/{core => common}/src/main/java/com/google/android/exoplayer2/device/package-info.java (100%) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java similarity index 100% rename from library/core/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java rename to library/common/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/device/package-info.java b/library/common/src/main/java/com/google/android/exoplayer2/device/package-info.java similarity index 100% rename from library/core/src/main/java/com/google/android/exoplayer2/device/package-info.java rename to library/common/src/main/java/com/google/android/exoplayer2/device/package-info.java From 1d1762d30d655f1e8b099dfa4ee7a36a633f96fd Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 14 Jul 2020 15:37:03 +0100 Subject: [PATCH 0665/1052] Add Builder to Transformer PiperOrigin-RevId: 321155415 --- .../main/java/com/google/android/exoplayer2/util/MimeTypes.java | 1 + 1 file changed, 1 insertion(+) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index cf86be8c46..1253f44883 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -52,6 +52,7 @@ public final class MimeTypes { public static final String VIDEO_DIVX = BASE_TYPE_VIDEO + "/divx"; public static final String VIDEO_FLV = BASE_TYPE_VIDEO + "/x-flv"; public static final String VIDEO_DOLBY_VISION = BASE_TYPE_VIDEO + "/dolby-vision"; + public static final String VIDEO_OGG = BASE_TYPE_VIDEO + "/ogg"; public static final String VIDEO_UNKNOWN = BASE_TYPE_VIDEO + "/x-unknown"; public static final String AUDIO_MP4 = BASE_TYPE_AUDIO + "/mp4"; From 7b69b47a5e4c1901d0382715516e26259f612972 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 15:45:35 +0100 Subject: [PATCH 0666/1052] Migrate usages of deprecated MediaSource#getTag() method PiperOrigin-RevId: 321156463 --- .../source/DefaultMediaSourceFactoryTest.java | 17 +++++++++++++++-- .../dash/DefaultMediaSourceFactoryTest.java | 2 +- .../hls/DefaultMediaSourceFactoryTest.java | 2 +- .../DefaultMediaSourceFactoryTest.java | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java index 3b4b38c3af..8dfe73f4ad 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java @@ -42,6 +42,17 @@ public final class DefaultMediaSourceFactoryTest { private static final String URI_MEDIA = "http://exoplayer.dev/video"; private static final String URI_TEXT = "http://exoplayer.dev/text"; + @Test + public void createMediaSource_fromMediaItem_returnsSameMediaItemInstance() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource.getMediaItem()).isSameInstanceAs(mediaItem); + } + @Test public void createMediaSource_withoutMimeType_progressiveSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = @@ -54,7 +65,8 @@ public final class DefaultMediaSourceFactoryTest { } @Test - public void createMediaSource_withTag_tagInSource() { + @SuppressWarnings("deprecation") // Testing deprecated MediaSource.getTag() still works. + public void createMediaSource_withTag_tagInSource_deprecated() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); @@ -109,7 +121,8 @@ public final class DefaultMediaSourceFactoryTest { } @Test - public void createMediaSource_withSubtitle_hasTag() { + @SuppressWarnings("deprecation") // Testing deprecated MediaSource.getTag() still works. + public void createMediaSource_withSubtitle_hasTag_deprecated() { DefaultMediaSourceFactory defaultMediaSourceFactory = DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); Object tag = new Object(); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java index 7c3fdfc5ac..4ed34b0164 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java @@ -59,7 +59,7 @@ public class DefaultMediaSourceFactoryTest { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource.getTag()).isEqualTo(tag); + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } @Test diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java index 0c9f54881d..d46da26ff2 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java @@ -59,7 +59,7 @@ public class DefaultMediaSourceFactoryTest { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource.getTag()).isEqualTo(tag); + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } @Test diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java index 37b686183f..016acdbf3d 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java @@ -59,7 +59,7 @@ public class DefaultMediaSourceFactoryTest { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource.getTag()).isEqualTo(tag); + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } @Test From 49db15ef73ac1e6672b3973152d8d648d3a9a5d8 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 15:50:54 +0100 Subject: [PATCH 0667/1052] Migrate usages of DownloadHelper TrackSelector constants PiperOrigin-RevId: 321157115 --- .../com/google/android/exoplayer2/offline/DownloadHelper.java | 2 +- .../google/android/exoplayer2/offline/DownloadHelperTest.java | 2 +- .../exoplayer2/source/dash/offline/DownloadHelperTest.java | 2 +- .../exoplayer2/source/hls/offline/DownloadHelperTest.java | 2 +- .../source/smoothstreaming/offline/DownloadHelperTest.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 6e86fd69e0..d9d57b18f7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -270,7 +270,7 @@ public final class DownloadHelper { dataSourceFactory, renderersFactory, /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT); } /** diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index f31fe7c191..bada7fc15c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -121,7 +121,7 @@ public class DownloadHelperTest { new DownloadHelper( testMediaItem, new TestMediaSource(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, DownloadHelper.getRendererCapabilities(renderersFactory)); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java index 9efe20c635..b2fae93bca 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java @@ -39,7 +39,7 @@ public final class DownloadHelperTest { new FakeDataSource.Factory()); DownloadHelper.forMediaItem( new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_MPD).build(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], new FakeDataSource.Factory(), /* drmSessionManager= */ DrmSessionManager.getDummyDrmSessionManager()); diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java index 834f0457b9..9d1127a3d7 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java @@ -44,7 +44,7 @@ public final class DownloadHelperTest { .setUri("http://uri") .setMimeType(MimeTypes.APPLICATION_M3U8) .build(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], new FakeDataSource.Factory(), /* drmSessionManager= */ null); diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java index 180deb3068..df1a0bd6da 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java @@ -38,7 +38,7 @@ public final class DownloadHelperTest { new FakeDataSource.Factory()); DownloadHelper.forMediaItem( new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_SS).build(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], new FakeDataSource.Factory(), /* drmSessionManager= */ null); From 93c9e93a090c72b679214c07bb7013a5218d8ccf Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 15:55:17 +0100 Subject: [PATCH 0668/1052] Migrate usages of renderer constants in C.java to the Renderer ones PiperOrigin-RevId: 321157794 --- extensions/av1/README.md | 27 ++++++++++--------- extensions/vp9/README.md | 27 ++++++++++--------- .../exoplayer2/ext/vp9/VpxPlaybackTest.java | 4 +-- .../exoplayer2/audio/AudioAttributes.java | 2 +- .../com/google/android/exoplayer2/Player.java | 6 ++--- .../android/exoplayer2/ExoPlayerTest.java | 2 +- 6 files changed, 37 insertions(+), 31 deletions(-) diff --git a/extensions/av1/README.md b/extensions/av1/README.md index 54e27a3b87..2515a53f3b 100644 --- a/extensions/av1/README.md +++ b/extensions/av1/README.md @@ -109,19 +109,22 @@ To try out playback using the extension in the [demo application][], see There are two possibilities for rendering the output `Libgav1VideoRenderer` gets from the libgav1 decoder: -* GL rendering using GL shader for color space conversion - * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option by - setting `surface_type` of `PlayerView` to be - `video_decoder_gl_surface_view`. - * Otherwise, enable this option by sending `Libgav1VideoRenderer` a message - of type `C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an instance of - `VideoDecoderOutputBufferRenderer` as its object. +* GL rendering using GL shader for color space conversion -* Native rendering using `ANativeWindow` - * If you are using `SimpleExoPlayer` with `PlayerView`, this option is enabled - by default. - * Otherwise, enable this option by sending `Libgav1VideoRenderer` a message of - type `C.MSG_SET_SURFACE` with an instance of `SurfaceView` as its object. + * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option + by setting `surface_type` of `PlayerView` to be + `video_decoder_gl_surface_view`. + * Otherwise, enable this option by sending `Libgav1VideoRenderer` a + message of type `Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` + with an instance of `VideoDecoderOutputBufferRenderer` as its object. + +* Native rendering using `ANativeWindow` + + * If you are using `SimpleExoPlayer` with `PlayerView`, this option is + enabled by default. + * Otherwise, enable this option by sending `Libgav1VideoRenderer` a + message of type `Renderer.MSG_SET_SURFACE` with an instance of + `SurfaceView` as its object. Note: Although the default option uses `ANativeWindow`, based on our testing the GL rendering mode has better performance, so should be preferred diff --git a/extensions/vp9/README.md b/extensions/vp9/README.md index 05628998ed..765cdbca3b 100644 --- a/extensions/vp9/README.md +++ b/extensions/vp9/README.md @@ -126,19 +126,22 @@ To try out playback using the extension in the [demo application][], see There are two possibilities for rendering the output `LibvpxVideoRenderer` gets from the libvpx decoder: -* GL rendering using GL shader for color space conversion - * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option by - setting `surface_type` of `PlayerView` to be - `video_decoder_gl_surface_view`. - * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message of - type `C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an instance of - `VideoDecoderOutputBufferRenderer` as its object. +* GL rendering using GL shader for color space conversion -* Native rendering using `ANativeWindow` - * If you are using `SimpleExoPlayer` with `PlayerView`, this option is enabled - by default. - * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message of - type `C.MSG_SET_SURFACE` with an instance of `SurfaceView` as its object. + * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option + by setting `surface_type` of `PlayerView` to be + `video_decoder_gl_surface_view`. + * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message + of type `Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an + instance of `VideoDecoderOutputBufferRenderer` as its object. + +* Native rendering using `ANativeWindow` + + * If you are using `SimpleExoPlayer` with `PlayerView`, this option is + enabled by default. + * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message + of type `Renderer.MSG_SET_SURFACE` with an instance of `SurfaceView` as + its object. Note: Although the default option uses `ANativeWindow`, based on our testing the GL rendering mode has better performance, so should be preferred. diff --git a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java index 210a5bbc8a..1786b6838e 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -24,11 +24,11 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; @@ -125,7 +125,7 @@ public class VpxPlaybackTest { .createMediaSource(MediaItem.fromUri(uri)); player .createMessage(videoRenderer) - .setType(C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) + .setType(Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) .setPayload(new VideoDecoderGLSurfaceView(context).getVideoDecoderOutputBufferRenderer()) .send(); player.setMediaSource(mediaSource); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java index a35383ec92..71ffb00982 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java @@ -25,7 +25,7 @@ import com.google.android.exoplayer2.util.Util; * android.media.AudioTrack}. * *

          To set the audio attributes, create an instance using the {@link Builder} and either pass it - * to the player or send a message of type {@link C#MSG_SET_AUDIO_ATTRIBUTES} to the audio + * to the player or send a message of type {@code Renderer#MSG_SET_AUDIO_ATTRIBUTES} to the audio * renderers. * *

          This class is based on {@link android.media.AudioAttributes}, but can be used on all supported diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 3541806293..47b93e0120 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -311,9 +311,9 @@ public interface Player { /** * Sets the video decoder output buffer renderer. This is intended for use only with extension - * renderers that accept {@link C#MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER}. For most use - * cases, an output surface or view should be passed via {@link #setVideoSurface(Surface)} or - * {@link #setVideoSurfaceView(SurfaceView)} instead. + * renderers that accept {@link Renderer#MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER}. For most + * use cases, an output surface or view should be passed via {@link #setVideoSurface(Surface)} + * or {@link #setVideoSurfaceView(SurfaceView)} instead. * * @param videoDecoderOutputBufferRenderer The video decoder output buffer renderer, or {@code * null} to clear the output buffer renderer. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 6959c5fe4b..d1a9d82ad4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -2443,7 +2443,7 @@ public final class ExoPlayerTest { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - assertThat(Collections.frequency(rendererMessages, C.MSG_SET_SURFACE)).isEqualTo(2); + assertThat(Collections.frequency(rendererMessages, Renderer.MSG_SET_SURFACE)).isEqualTo(2); } @Test From 683d630ec6e57fd5087b2e2fd6288bbed88a1d54 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 15:58:18 +0100 Subject: [PATCH 0669/1052] Migrate usage of deprecated OfflineLicenseHelper constructor PiperOrigin-RevId: 321158149 --- .../android/exoplayer2/drm/OfflineLicenseHelperTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java index f7b249765b..d57b3bb6e8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java @@ -52,10 +52,10 @@ public class OfflineLicenseHelperTest { new ExoMediaDrm.KeyRequest(/* data= */ new byte[0], /* licenseServerUrl= */ "")); offlineLicenseHelper = new OfflineLicenseHelper( - C.WIDEVINE_UUID, - new ExoMediaDrm.AppManagedProvider(mediaDrm), - mediaDrmCallback, - /* optionalKeyRequestParameters= */ null, + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + C.WIDEVINE_UUID, new ExoMediaDrm.AppManagedProvider(mediaDrm)) + .build(mediaDrmCallback), new DrmSessionEventListener.EventDispatcher()); } From d62688cfc00ad6507ee4d9c65c56c16e9eb1b7c9 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 14 Jul 2020 16:11:20 +0100 Subject: [PATCH 0670/1052] Mask periodId and loadingPeriodId This change masks playbackInfo.periodId and playbackInfo.loadingPeriodId for operations which change these periods (set/add/remove sources and seeks). Because this masking is reflected in the playbackInfo object, player attributes can be retrieved without the maskingXyz variables in EPI. This has the advantage that the playbackInfo object always reflects the public state of the player even when operations are still pending. The maskingXyz variables in EPI are only required for the deprecated use case of an initial seek in an empty timeline. PiperOrigin-RevId: 321160092 --- .../android/exoplayer2/ExoPlayerImpl.java | 337 +++-- .../android/exoplayer2/ExoPlayerTest.java | 1309 +++++++++++++++-- .../analytics/AnalyticsCollectorTest.java | 4 +- .../exoplayer2/testutil/FakeMediaPeriod.java | 11 +- 4 files changed, 1433 insertions(+), 228 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 2ddf09295e..8482a584d2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.castNonNull; import android.annotation.SuppressLint; import android.os.Handler; @@ -418,16 +419,18 @@ import java.util.concurrent.TimeoutException; public void addMediaSources(int index, List mediaSources) { Assertions.checkArgument(index >= 0); validateMediaSources(mediaSources, /* mediaSourceReplacement= */ false); - int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); pendingOperationAcks++; List holders = addMediaSourceHolders(index, mediaSources); - PlaybackInfo playbackInfo = - maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + Timeline newTimeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + newTimeline, + getPeriodPositionAfterTimelineChanged(oldTimeline, newTimeline)); internalPlayer.addMediaSources(index, holders, shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -448,18 +451,20 @@ import java.util.concurrent.TimeoutException; && fromIndex <= toIndex && toIndex <= mediaSourceHolderSnapshots.size() && newFromIndex >= 0); - int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); pendingOperationAcks++; newFromIndex = Math.min(newFromIndex, mediaSourceHolderSnapshots.size() - (toIndex - fromIndex)); Util.moveItems(mediaSourceHolderSnapshots, fromIndex, toIndex, newFromIndex); - PlaybackInfo playbackInfo = - maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + Timeline newTimeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + newTimeline, + getPeriodPositionAfterTimelineChanged(oldTimeline, newTimeline)); internalPlayer.moveMediaSources(fromIndex, toIndex, newFromIndex, shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -477,13 +482,18 @@ import java.util.concurrent.TimeoutException; @Override public void setShuffleOrder(ShuffleOrder shuffleOrder) { - PlaybackInfo playbackInfo = maskTimeline(); - maskWithCurrentPosition(); + Timeline timeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + timeline, + getPeriodPositionOrMaskWindowPosition( + timeline, getCurrentWindowIndex(), getCurrentPosition())); pendingOperationAcks++; this.shuffleOrder = shuffleOrder; internalPlayer.setShuffleOrder(shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -521,7 +531,6 @@ import java.util.concurrent.TimeoutException; && playbackInfo.playbackSuppressionReason == playbackSuppressionReason) { return; } - maskWithCurrentPosition(); pendingOperationAcks++; PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); @@ -589,14 +598,18 @@ import java.util.concurrent.TimeoutException; new ExoPlayerImplInternal.PlaybackInfoUpdate(playbackInfo)); return; } - maskWindowIndexAndPositionForSeek(timeline, windowIndex, positionMs); @Player.State int newPlaybackState = getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : Player.STATE_BUFFERING; - PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlaybackState(newPlaybackState); + PlaybackInfo newPlaybackInfo = this.playbackInfo.copyWithPlaybackState(newPlaybackState); + newPlaybackInfo = + maskTimelineAndPosition( + newPlaybackInfo, + timeline, + getPeriodPositionOrMaskWindowPosition(timeline, windowIndex, positionMs)); internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs)); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ true, /* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK, /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -732,7 +745,7 @@ import java.util.concurrent.TimeoutException; @Override public int getCurrentPeriodIndex() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingPeriodIndex; } else { return playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); @@ -758,7 +771,7 @@ import java.util.concurrent.TimeoutException; @Override public long getCurrentPosition() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; } else if (playbackInfo.periodId.isAd()) { return C.usToMs(playbackInfo.positionUs); @@ -784,7 +797,7 @@ import java.util.concurrent.TimeoutException; @Override public boolean isPlayingAd() { - return !shouldMaskPosition() && playbackInfo.periodId.isAd(); + return playbackInfo.periodId.isAd(); } @Override @@ -811,7 +824,7 @@ import java.util.concurrent.TimeoutException; @Override public long getContentBufferedPosition() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; } if (playbackInfo.loadingMediaPeriodId.windowSequenceNumber @@ -864,7 +877,7 @@ import java.util.concurrent.TimeoutException; } private int getCurrentWindowIndexInternal() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowIndex; } else { return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) @@ -909,7 +922,8 @@ import java.util.concurrent.TimeoutException; if (pendingOperationAcks == 0) { Timeline newTimeline = playbackInfoUpdate.playbackInfo.timeline; if (!this.playbackInfo.timeline.isEmpty() && newTimeline.isEmpty()) { - // Update the masking variables, which are used when the timeline becomes empty. + // Update the masking variables, which are used when the timeline becomes empty because a + // ConcatenatingMediaSource has been cleared. resetMaskingPosition(); } if (!newTimeline.isEmpty()) { @@ -938,8 +952,6 @@ import java.util.concurrent.TimeoutException; removeMediaSourceHolders( /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolderSnapshots.size()); resetMaskingPosition(); - } else { - maskWithCurrentPosition(); } Timeline timeline = playbackInfo.timeline; MediaPeriodId mediaPeriodId = playbackInfo.periodId; @@ -1006,8 +1018,7 @@ import java.util.concurrent.TimeoutException; } List holders = addMediaSourceHolders(/* index= */ 0, mediaSources); - PlaybackInfo playbackInfo = maskTimeline(); - Timeline timeline = playbackInfo.timeline; + Timeline timeline = createMaskingTimeline(); if (!timeline.isEmpty() && startWindowIndex >= timeline.getWindowCount()) { throw new IllegalSeekPositionException(timeline, startWindowIndex, startPositionMs); } @@ -1019,11 +1030,14 @@ import java.util.concurrent.TimeoutException; startWindowIndex = currentWindowIndex; startPositionMs = currentPositionMs; } - maskWindowIndexAndPositionForSeek( - timeline, startWindowIndex == C.INDEX_UNSET ? 0 : startWindowIndex, startPositionMs); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + timeline, + getPeriodPositionOrMaskWindowPosition(timeline, startWindowIndex, startPositionMs)); // Mask the playback state. - int maskingPlaybackState = playbackInfo.playbackState; - if (startWindowIndex != C.INDEX_UNSET && playbackInfo.playbackState != STATE_IDLE) { + int maskingPlaybackState = newPlaybackInfo.playbackState; + if (startWindowIndex != C.INDEX_UNSET && newPlaybackInfo.playbackState != STATE_IDLE) { // Position reset to startWindowIndex (results in pending initial seek). if (timeline.isEmpty() || startWindowIndex >= timeline.getWindowCount()) { // Setting an empty timeline or invalid seek transitions to ended. @@ -1032,11 +1046,11 @@ import java.util.concurrent.TimeoutException; maskingPlaybackState = STATE_BUFFERING; } } - playbackInfo = playbackInfo.copyWithPlaybackState(maskingPlaybackState); + newPlaybackInfo = newPlaybackInfo.copyWithPlaybackState(maskingPlaybackState); internalPlayer.setMediaSources( holders, startWindowIndex, C.msToUs(startPositionMs), shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -1064,26 +1078,29 @@ import java.util.concurrent.TimeoutException; Assertions.checkArgument( fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolderSnapshots.size()); int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); int currentMediaSourceCount = mediaSourceHolderSnapshots.size(); pendingOperationAcks++; removeMediaSourceHolders(fromIndex, /* toIndexExclusive= */ toIndex); - PlaybackInfo playbackInfo = - maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + Timeline newTimeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + newTimeline, + getPeriodPositionAfterTimelineChanged(oldTimeline, newTimeline)); // Player transitions to STATE_ENDED if the current index is part of the removed tail. final boolean transitionsToEnded = - playbackInfo.playbackState != STATE_IDLE - && playbackInfo.playbackState != STATE_ENDED + newPlaybackInfo.playbackState != STATE_IDLE + && newPlaybackInfo.playbackState != STATE_ENDED && fromIndex < toIndex && toIndex == currentMediaSourceCount - && currentWindowIndex >= playbackInfo.timeline.getWindowCount(); + && currentWindowIndex >= newPlaybackInfo.timeline.getWindowCount(); if (transitionsToEnded) { - playbackInfo = playbackInfo.copyWithPlaybackState(STATE_ENDED); + newPlaybackInfo = newPlaybackInfo.copyWithPlaybackState(STATE_ENDED); } internalPlayer.removeMediaSources(fromIndex, toIndex, shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -1131,107 +1148,163 @@ import java.util.concurrent.TimeoutException; } } - private PlaybackInfo maskTimeline() { - return playbackInfo.copyWithTimeline( - mediaSourceHolderSnapshots.isEmpty() - ? Timeline.EMPTY - : new PlaylistTimeline(mediaSourceHolderSnapshots, shuffleOrder)); + private Timeline createMaskingTimeline() { + return new PlaylistTimeline(mediaSourceHolderSnapshots, shuffleOrder); } - private PlaybackInfo maskTimelineAndWindowIndex( - int currentWindowIndex, long currentPositionMs, Timeline oldTimeline) { - PlaybackInfo playbackInfo = maskTimeline(); - Timeline maskingTimeline = playbackInfo.timeline; - if (oldTimeline.isEmpty()) { - // The index is the default index or was set by a seek in the empty old timeline. - maskingWindowIndex = currentWindowIndex; - if (!maskingTimeline.isEmpty() && currentWindowIndex >= maskingTimeline.getWindowCount()) { - // The seek is not valid in the new timeline. - maskWithDefaultPosition(maskingTimeline); - } + private PlaybackInfo maskTimelineAndPosition( + PlaybackInfo playbackInfo, Timeline timeline, @Nullable Pair periodPosition) { + Assertions.checkArgument(timeline.isEmpty() || periodPosition != null); + Timeline oldTimeline = playbackInfo.timeline; + // Mask the timeline. + playbackInfo = playbackInfo.copyWithTimeline(timeline); + + if (timeline.isEmpty()) { + // Reset periodId and loadingPeriodId. + MediaPeriodId dummyMediaPeriodId = PlaybackInfo.getDummyPeriodForEmptyTimeline(); + playbackInfo = + playbackInfo.copyWithNewPosition( + dummyMediaPeriodId, + /* positionUs= */ C.msToUs(maskingWindowPositionMs), + /* requestedContentPositionUs= */ C.msToUs(maskingWindowPositionMs), + /* totalBufferedDurationUs= */ 0, + TrackGroupArray.EMPTY, + emptyTrackSelectorResult); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(dummyMediaPeriodId); + playbackInfo.bufferedPositionUs = playbackInfo.positionUs; return playbackInfo; } - @Nullable - Pair periodPosition = - oldTimeline.getPeriodPosition( - window, - period, - currentWindowIndex, - C.msToUs(currentPositionMs), - /* defaultPositionProjectionUs= */ 0); - Object periodUid = Util.castNonNull(periodPosition).first; - if (maskingTimeline.getIndexOfPeriod(periodUid) != C.INDEX_UNSET) { - // Get the window index of the current period that exists in the new timeline also. - maskingWindowIndex = maskingTimeline.getPeriodByUid(periodUid, period).windowIndex; - maskingPeriodIndex = maskingTimeline.getIndexOfPeriod(periodUid); - maskingWindowPositionMs = currentPositionMs; - } else { - // Period uid not found in new timeline. Try to get subsequent period. - @Nullable - Object nextPeriodUid = - ExoPlayerImplInternal.resolveSubsequentPeriod( - window, - period, - repeatMode, - shuffleModeEnabled, - periodUid, - oldTimeline, - maskingTimeline); - if (nextPeriodUid != null) { - // Set masking to the default position of the window of the subsequent period. - maskingWindowIndex = maskingTimeline.getPeriodByUid(nextPeriodUid, period).windowIndex; - maskingPeriodIndex = maskingTimeline.getWindow(maskingWindowIndex, window).firstPeriodIndex; - maskingWindowPositionMs = window.getDefaultPositionMs(); - } else { - // Reset if no subsequent period is found. - maskWithDefaultPosition(maskingTimeline); + + Object oldPeriodUid = playbackInfo.periodId.periodUid; + boolean playingPeriodChanged = !oldPeriodUid.equals(castNonNull(periodPosition).first); + MediaPeriodId newPeriodId = + playingPeriodChanged ? new MediaPeriodId(periodPosition.first) : playbackInfo.periodId; + long newContentPositionUs = periodPosition.second; + long oldContentPositionUs = C.msToUs(getContentPosition()); + if (!oldTimeline.isEmpty()) { + oldContentPositionUs -= + oldTimeline.getPeriodByUid(oldPeriodUid, period).getPositionInWindowUs(); + } + + if (playingPeriodChanged || newContentPositionUs < oldContentPositionUs) { + checkState(!newPeriodId.isAd()); + // The playing period changes or a backwards seek within the playing period occurs. + playbackInfo = + playbackInfo.copyWithNewPosition( + newPeriodId, + /* positionUs= */ newContentPositionUs, + /* requestedContentPositionUs= */ newContentPositionUs, + /* totalBufferedDurationUs= */ 0, + playingPeriodChanged ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, + playingPeriodChanged ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(newPeriodId); + playbackInfo.bufferedPositionUs = newContentPositionUs; + } else if (newContentPositionUs == oldContentPositionUs) { + // Period position remains unchanged. + int loadingPeriodIndex = + timeline.getIndexOfPeriod(playbackInfo.loadingMediaPeriodId.periodUid); + if (loadingPeriodIndex == C.INDEX_UNSET + || timeline.getPeriod(loadingPeriodIndex, period).windowIndex + != timeline.getPeriodByUid(newPeriodId.periodUid, period).windowIndex) { + // Discard periods after the playing period, if the loading period is discarded or the + // playing and loading period are not in the same window. + timeline.getPeriodByUid(newPeriodId.periodUid, period); + long maskedBufferedPositionUs = + newPeriodId.isAd() + ? period.getAdDurationUs(newPeriodId.adGroupIndex, newPeriodId.adIndexInAdGroup) + : period.durationUs; + playbackInfo = + playbackInfo.copyWithNewPosition( + newPeriodId, + /* positionUs= */ playbackInfo.positionUs, + /* requestedContentPositionUs= */ playbackInfo.positionUs, + /* totalBufferedDurationUs= */ maskedBufferedPositionUs - playbackInfo.positionUs, + playbackInfo.trackGroups, + playbackInfo.trackSelectorResult); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(newPeriodId); + playbackInfo.bufferedPositionUs = maskedBufferedPositionUs; } + } else { + checkState(!newPeriodId.isAd()); + // A forward seek within the playing period (timeline did not change). + long maskedTotalBufferedDurationUs = + Math.max( + 0, + playbackInfo.totalBufferedDurationUs - (newContentPositionUs - oldContentPositionUs)); + long maskedBufferedPositionUs = playbackInfo.bufferedPositionUs; + if (playbackInfo.loadingMediaPeriodId.equals(playbackInfo.periodId)) { + maskedBufferedPositionUs = newContentPositionUs + maskedTotalBufferedDurationUs; + } + playbackInfo = + playbackInfo.copyWithNewPosition( + newPeriodId, + /* positionUs= */ newContentPositionUs, + /* requestedContentPositionUs= */ newContentPositionUs, + maskedTotalBufferedDurationUs, + playbackInfo.trackGroups, + playbackInfo.trackSelectorResult); + playbackInfo.bufferedPositionUs = maskedBufferedPositionUs; } return playbackInfo; } - private void maskWindowIndexAndPositionForSeek( - Timeline timeline, int windowIndex, long positionMs) { - maskingWindowIndex = windowIndex; - if (timeline.isEmpty()) { - maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs; - maskingPeriodIndex = 0; - } else if (windowIndex >= timeline.getWindowCount()) { - // An initial seek now proves to be invalid in the actual timeline. - maskWithDefaultPosition(timeline); + @Nullable + private Pair getPeriodPositionAfterTimelineChanged( + Timeline oldTimeline, Timeline newTimeline) { + long currentPositionMs = getContentPosition(); + if (oldTimeline.isEmpty() || newTimeline.isEmpty()) { + boolean isCleared = !oldTimeline.isEmpty() && newTimeline.isEmpty(); + return getPeriodPositionOrMaskWindowPosition( + newTimeline, + isCleared ? C.INDEX_UNSET : getCurrentWindowIndexInternal(), + isCleared ? C.TIME_UNSET : currentPositionMs); + } + int currentWindowIndex = getCurrentWindowIndex(); + @Nullable + Pair oldPeriodPosition = + oldTimeline.getPeriodPosition( + window, period, currentWindowIndex, C.msToUs(currentPositionMs)); + Object periodUid = castNonNull(oldPeriodPosition).first; + if (newTimeline.getIndexOfPeriod(periodUid) != C.INDEX_UNSET) { + // The old period position is still available in the new timeline. + return oldPeriodPosition; + } + // Period uid not found in new timeline. Try to get subsequent period. + @Nullable + Object nextPeriodUid = + ExoPlayerImplInternal.resolveSubsequentPeriod( + window, period, repeatMode, shuffleModeEnabled, periodUid, oldTimeline, newTimeline); + if (nextPeriodUid != null) { + // Reset position to the default position of the window of the subsequent period. + newTimeline.getPeriodByUid(nextPeriodUid, period); + return getPeriodPositionOrMaskWindowPosition( + newTimeline, + period.windowIndex, + newTimeline.getWindow(period.windowIndex, window).getDefaultPositionMs()); } else { - long windowPositionUs = - positionMs == C.TIME_UNSET - ? timeline.getWindow(windowIndex, window).getDefaultPositionUs() - : C.msToUs(positionMs); - Pair periodUidAndPosition = - timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs); - maskingWindowPositionMs = C.usToMs(windowPositionUs); - maskingPeriodIndex = timeline.getIndexOfPeriod(periodUidAndPosition.first); + // No subsequent period found and the new timeline is not empty. Use the default position. + return getPeriodPositionOrMaskWindowPosition( + newTimeline, /* windowIndex= */ C.INDEX_UNSET, /* windowPositionMs= */ C.TIME_UNSET); } } - private void maskWithCurrentPosition() { - maskingWindowIndex = getCurrentWindowIndexInternal(); - maskingPeriodIndex = getCurrentPeriodIndex(); - maskingWindowPositionMs = getCurrentPosition(); - } - - private void maskWithDefaultPosition(Timeline timeline) { + @Nullable + private Pair getPeriodPositionOrMaskWindowPosition( + Timeline timeline, int windowIndex, long windowPositionMs) { if (timeline.isEmpty()) { - resetMaskingPosition(); - return; + // If empty we store the initial seek in the masking variables. + maskingWindowIndex = windowIndex; + maskingWindowPositionMs = windowPositionMs == C.TIME_UNSET ? 0 : windowPositionMs; + maskingPeriodIndex = 0; + return null; } - maskingWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); - timeline.getWindow(maskingWindowIndex, window); - maskingWindowPositionMs = window.getDefaultPositionMs(); - maskingPeriodIndex = window.firstPeriodIndex; - } - - private void resetMaskingPosition() { - maskingWindowIndex = C.INDEX_UNSET; - maskingWindowPositionMs = 0; - maskingPeriodIndex = 0; + if (windowIndex == C.INDEX_UNSET || windowIndex >= timeline.getWindowCount()) { + // Use default position of timeline if window index still unset or if a previous initial seek + // now turns out to be invalid. + windowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); + windowPositionMs = timeline.getWindow(windowIndex, window).getDefaultPositionMs(); + } + return timeline.getPeriodPosition(window, period, windowIndex, C.msToUs(windowPositionMs)); } private void notifyListeners(ListenerInvocation listenerInvocation) { @@ -1251,6 +1324,12 @@ import java.util.concurrent.TimeoutException; } } + private void resetMaskingPosition() { + maskingWindowIndex = C.INDEX_UNSET; + maskingWindowPositionMs = 0; + maskingPeriodIndex = 0; + } + private long periodPositionUsToWindowPositionMs(MediaPeriodId periodId, long positionUs) { long positionMs = C.usToMs(positionUs); playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period); @@ -1258,10 +1337,6 @@ import java.util.concurrent.TimeoutException; return positionMs; } - private boolean shouldMaskPosition() { - return playbackInfo.timeline.isEmpty() || pendingOperationAcks > 0; - } - private final class PlaybackUpdateListenerImpl implements ExoPlayerImplInternal.PlaybackUpdateListener, Handler.Callback { private static final int MSG_PLAYBACK_INFO_CHANGED = 0; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index d1a9d82ad4..947a2975d0 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -32,6 +32,7 @@ import android.content.Context; import android.content.Intent; import android.graphics.SurfaceTexture; import android.media.AudioManager; +import android.net.Uri; import android.os.Looper; import android.view.Surface; import android.view.View; @@ -1062,131 +1063,172 @@ public final class ExoPlayerTest { } @Test - public void stopDoesNotResetPosition() throws Exception { + public void stop_withoutReset_doesNotResetPosition_correctMasking() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final long[] positionHolder = new long[1]; + int[] currentWindowIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDuration = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 50) - .stop() .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - positionHolder[0] = player.getCurrentPosition(); + currentWindowIndex[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + player.stop(/* reset= */ false); + currentWindowIndex[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[2] = player.getCurrentWindowIndex(); + currentPosition[2] = player.getCurrentPosition(); + bufferedPosition[2] = player.getBufferedPosition(); + totalBufferedDuration[2] = player.getTotalBufferedDuration(); } }) .build(); + ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) + .setMediaSources(mediaSource, mediaSource) .setActionSchedule(actionSchedule) .build() .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(placeholderTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); - testRunner.assertNoPositionDiscontinuities(); - assertThat(positionHolder[0]).isAtLeast(50L); + testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); + + assertThat(currentWindowIndex[0]).isEqualTo(1); + assertThat(currentPosition[0]).isEqualTo(1000); + assertThat(bufferedPosition[0]).isEqualTo(10000); + assertThat(totalBufferedDuration[0]).isEqualTo(9000); + + assertThat(currentWindowIndex[1]).isEqualTo(1); + assertThat(currentPosition[1]).isEqualTo(1000); + assertThat(bufferedPosition[1]).isEqualTo(1000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + + assertThat(currentWindowIndex[2]).isEqualTo(1); + assertThat(currentPosition[2]).isEqualTo(1000); + assertThat(bufferedPosition[2]).isEqualTo(1000); + assertThat(totalBufferedDuration[2]).isEqualTo(0); } @Test - public void stopWithoutResetDoesNotResetPosition() throws Exception { + public void stop_withoutReset_releasesMediaSource() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final long[] positionHolder = new long[1]; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) - .pause() .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 50) .stop(/* reset= */ false) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - positionHolder[0] = player.getCurrentPosition(); - } - }) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(placeholderTimeline, timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); - testRunner.assertNoPositionDiscontinuities(); - assertThat(positionHolder[0]).isAtLeast(50L); + + new ExoPlayerTestRunner.Builder(context) + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + mediaSource.assertReleased(); } @Test - public void stopWithResetDoesResetPosition() throws Exception { + public void stop_withReset_doesResetPosition_correctMasking() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final long[] positionHolder = new long[1]; + int[] currentWindowIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDuration = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 50) - .stop(/* reset= */ true) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - positionHolder[0] = player.getCurrentPosition(); + currentWindowIndex[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + player.stop(/* reset= */ true); + currentWindowIndex[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[2] = player.getCurrentWindowIndex(); + currentPosition[2] = player.getCurrentPosition(); + bufferedPosition[2] = player.getBufferedPosition(); + totalBufferedDuration[2] = player.getTotalBufferedDuration(); } }) .build(); + ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) + .setMediaSources(mediaSource, mediaSource) .setActionSchedule(actionSchedule) .build() .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(placeholderTimeline, timeline, Timeline.EMPTY); + .blockUntilActionScheduleFinished(TIMEOUT_MS); + testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); - testRunner.assertNoPositionDiscontinuities(); - assertThat(positionHolder[0]).isEqualTo(0); + testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); + + assertThat(currentWindowIndex[0]).isEqualTo(1); + assertThat(currentPosition[0]).isGreaterThan(0); + assertThat(bufferedPosition[0]).isEqualTo(10000); + assertThat(totalBufferedDuration[0]).isEqualTo(10000 - currentPosition[0]); + + assertThat(currentWindowIndex[1]).isEqualTo(0); + assertThat(currentPosition[1]).isEqualTo(0); + assertThat(bufferedPosition[1]).isEqualTo(0); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + + assertThat(currentWindowIndex[2]).isEqualTo(0); + assertThat(currentPosition[2]).isEqualTo(0); + assertThat(bufferedPosition[2]).isEqualTo(0); + assertThat(totalBufferedDuration[2]).isEqualTo(0); } @Test - public void stopWithoutResetReleasesMediaSource() throws Exception { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final FakeMediaSource mediaSource = - new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .waitForPlaybackState(Player.STATE_READY) - .stop(/* reset= */ false) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS); - mediaSource.assertReleased(); - testRunner.blockUntilEnded(TIMEOUT_MS); - } - - @Test - public void stopWithResetReleasesMediaSource() throws Exception { + public void stop_withReset_releasesMediaSource() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); final FakeMediaSource mediaSource = new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); @@ -1195,15 +1237,81 @@ public final class ExoPlayerTest { .waitForPlaybackState(Player.STATE_READY) .stop(/* reset= */ true) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS); + + new ExoPlayerTestRunner.Builder(context) + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + mediaSource.assertReleased(); - testRunner.blockUntilEnded(TIMEOUT_MS); + } + + @Test + public void release_correctMasking() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + int[] currentWindowIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDuration = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + player.release(); + currentWindowIndex[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[2] = player.getCurrentWindowIndex(); + currentPosition[2] = player.getCurrentPosition(); + bufferedPosition[2] = player.getBufferedPosition(); + totalBufferedDuration[2] = player.getTotalBufferedDuration(); + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource, mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS); + + assertThat(currentWindowIndex[0]).isEqualTo(1); + assertThat(currentPosition[0]).isGreaterThan(0); + assertThat(bufferedPosition[0]).isEqualTo(10000); + assertThat(totalBufferedDuration[0]).isEqualTo(10000 - currentPosition[0]); + + assertThat(currentWindowIndex[1]).isEqualTo(1); + assertThat(currentPosition[1]).isEqualTo(currentPosition[0]); + assertThat(bufferedPosition[1]).isEqualTo(1000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + + assertThat(currentWindowIndex[2]).isEqualTo(1); + assertThat(currentPosition[2]).isEqualTo(currentPosition[0]); + assertThat(bufferedPosition[2]).isEqualTo(1000); + assertThat(totalBufferedDuration[2]).isEqualTo(0); } @Test @@ -3533,26 +3641,34 @@ public final class ExoPlayerTest { FakeMediaSource mediaSource = new FakeMediaSource(fakeTimeline); LoopingMediaSource loopingMediaSource = new LoopingMediaSource(mediaSource, 2); final int[] windowIndex = {C.INDEX_UNSET}; - final long[] positionMs = {C.TIME_UNSET}; + final long[] positionMs = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 5000) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 3000) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { + positionMs[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); + //noinspection deprecation player.prepare(mediaSource); - player.seekTo(/* positionMs= */ 5000); + player.seekTo(/* positionMs= */ 7000); + positionMs[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { windowIndex[0] = player.getCurrentWindowIndex(); - positionMs[0] = player.getCurrentPosition(); + positionMs[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); @@ -3564,7 +3680,13 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS); assertThat(windowIndex[0]).isEqualTo(0); - assertThat(positionMs[0]).isAtLeast(5000L); + assertThat(positionMs[0]).isAtLeast(3000L); + assertThat(positionMs[1]).isEqualTo(7000L); + assertThat(positionMs[2]).isEqualTo(7000L); + assertThat(bufferedPositions[0]).isAtLeast(3000L); + assertThat(bufferedPositions[1]).isEqualTo(7000L); + assertThat(bufferedPositions[2]) + .isEqualTo(fakeTimeline.getWindow(0, new Window()).getDurationMs()); } @Test @@ -3573,26 +3695,34 @@ public final class ExoPlayerTest { FakeMediaSource mediaSource = new FakeMediaSource(fakeTimeline); LoopingMediaSource loopingMediaSource = new LoopingMediaSource(mediaSource, 2); final int[] windowIndex = {C.INDEX_UNSET}; - final long[] positionMs = {C.TIME_UNSET}; + final long[] positionMs = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 5000) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 3000) + .pause() .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - player.setMediaSource(mediaSource, /* startPositionMs= */ 5000); + positionMs[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); + player.setMediaSource(mediaSource, /* startPositionMs= */ 7000); player.prepare(); + positionMs[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { windowIndex[0] = player.getCurrentWindowIndex(); - positionMs[0] = player.getCurrentPosition(); + positionMs[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); @@ -3604,7 +3734,819 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS); assertThat(windowIndex[0]).isEqualTo(0); - assertThat(positionMs[0]).isAtLeast(5000L); + assertThat(positionMs[0]).isAtLeast(3000); + assertThat(positionMs[1]).isEqualTo(7000); + assertThat(positionMs[2]).isEqualTo(7000); + assertThat(bufferedPositions[0]).isAtLeast(3000); + assertThat(bufferedPositions[1]).isEqualTo(7000); + assertThat(bufferedPositions[2]) + .isEqualTo(fakeTimeline.getWindow(0, new Window()).getDurationMs()); + } + + @Test + public void seekTo_singlePeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(9000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(9000); + assertThat(bufferedPositions[0]).isEqualTo(9200); + assertThat(totalBufferedDuration[0]).isEqualTo(200); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(9200); + assertThat(totalBufferedDuration[1]).isEqualTo(200); + } + + @Test + public void seekTo_singlePeriod_beyondBufferedData_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(9200); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(9200); + assertThat(bufferedPositions[0]).isEqualTo(9200); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(9200); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + } + + @Test + public void seekTo_backwardsSinglePeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + } + + @Test + public void seekTo_backwardsMultiplePeriods_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(0, 1000); + } + }, + /* pauseWindowIndex= */ 1, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + } + + @Test + public void seekTo_toUnbufferedPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(2, 1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 0)); + + assertThat(windowIndex[0]).isEqualTo(2); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(2); + assertThat(positionMs[1]).isEqualTo(1000); + assertThat(bufferedPositions[1]).isEqualTo(1000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + } + + @Test + public void seekTo_toLoadingPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(1000); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(10_000); + // assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void seekTo_toLoadingPeriod_withinPartiallyBufferedData_correctMaskingPosition() + throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(1000); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(1000); + // assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(4000); + assertThat(totalBufferedDuration[1]).isEqualTo(3000); + } + + @Test + public void seekTo_toLoadingPeriod_beyondBufferedData_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 5000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(5000); + assertThat(bufferedPositions[0]).isEqualTo(5000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(1); + assertThat(positionMs[1]).isEqualTo(5000); + assertThat(bufferedPositions[1]).isEqualTo(5000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + } + + @Test + public void seekTo_toInnerFullyBufferedPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 5000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(5000); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(10_000); + // assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void addMediaSource_withinBufferedPeriods_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + /* index= */ 1, createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 0)); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void moveMediaItem_behindLoadingPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 2); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void moveMediaItem_undloadedBehindPlaying_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.moveMediaItem(/* currentIndex= */ 3, /* newIndex= */ 1); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 0)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void removeMediaItem_removePlayingWindow_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.removeMediaItem(/* index= */ 0); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(0); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(4000); + // assertThat(totalBufferedDuration[0]).isEqualTo(4000); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(4000); + assertThat(totalBufferedDuration[1]).isEqualTo(4000); + } + + @Test + public void removeMediaItem_removeLoadingWindow_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.removeMediaItem(/* index= */ 2); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void removeMediaItem_removeInnerFullyBufferedWindow_correctMaskingPosition() + throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.removeMediaItem(/* index= */ 1); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isGreaterThan(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(0); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[0]); + } + + @Test + public void clearMediaItems_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.clearMediaItems(); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(0); + assertThat(bufferedPositions[0]).isEqualTo(0); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(bufferedPositions[0]); + assertThat(totalBufferedDuration[1]).isEqualTo(totalBufferedDuration[0]); + } + + private void runPositionMaskingCapturingActionSchedule( + PlayerRunnable actionRunnable, + int pauseWindowIndex, + int[] windowIndex, + long[] positionMs, + long[] bufferedPosition, + long[] totalBufferedDuration, + MediaSource... mediaSources) + throws Exception { + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .playUntilPosition(pauseWindowIndex, /* positionMs= */ 8000) + .executeRunnable(actionRunnable) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[0] = player.getCurrentWindowIndex(); + positionMs[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + } + }) + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[1] = player.getCurrentWindowIndex(); + positionMs[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .stop() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSources) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + } + + private static FakeMediaSource createPartiallyBufferedMediaSource(long maxBufferedPositionMs) { + int windowOffsetInFirstPeriodUs = 1_000_000; + FakeTimeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000L, + /* defaultPositionUs= */ 0, + windowOffsetInFirstPeriodUs, + AdPlaybackState.NONE)); + return new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + FakeMediaPeriod fakeMediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + FakeMediaPeriod.TrackDataFactory.singleSampleWithTimeUs(/* sampleTimeUs= */ 0), + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false); + fakeMediaPeriod.setBufferedPositionUs( + windowOffsetInFirstPeriodUs + C.msToUs(maxBufferedPositionMs)); + return fakeMediaPeriod; + } + }; + } + + @Test + public void addMediaSource_whilePlayingAd_correctMasking() throws Exception { + long contentDurationMs = 10_000; + long adDurationMs = 100_000; + AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0); + adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + adPlaybackState = + adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY); + long[][] durationsUs = new long[1][]; + durationsUs[0] = new long[] {C.msToUs(adDurationMs)}; + adPlaybackState = adPlaybackState.withAdDurationsUs(durationsUs); + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(contentDurationMs), + adPlaybackState)); + FakeMediaSource adsMediaSource = new FakeMediaSource(adTimeline); + int[] windowIndex = new int[] {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] positionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.INDEX_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.INDEX_UNSET}; + long[] totalBufferedDurationMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.INDEX_UNSET}; + boolean[] isPlayingAd = new boolean[3]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .waitForIsLoading(true) + .waitForIsLoading(false) + .waitForIsLoading(true) + .waitForIsLoading(false) + .pause() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + /* index= */ 1, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + windowIndex[0] = player.getCurrentWindowIndex(); + isPlayingAd[0] = player.isPlayingAd(); + positionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + totalBufferedDurationMs[0] = player.getTotalBufferedDuration(); + } + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[1] = player.getCurrentWindowIndex(); + isPlayingAd[1] = player.isPlayingAd(); + positionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + totalBufferedDurationMs[1] = player.getTotalBufferedDuration(); + } + }) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 8000) + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + windowIndex[2] = player.getCurrentWindowIndex(); + isPlayingAd[2] = player.isPlayingAd(); + positionMs[2] = player.getCurrentPosition(); + bufferedPositionMs[2] = player.getBufferedPosition(); + totalBufferedDurationMs[2] = player.getTotalBufferedDuration(); + } + }) + .play() + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources( + adsMediaSource, new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(isPlayingAd[0]).isTrue(); + assertThat(positionMs[0]).isAtMost(adDurationMs); + assertThat(bufferedPositionMs[0]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[0]).isEqualTo(adDurationMs - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(0); + assertThat(isPlayingAd[1]).isTrue(); + assertThat(positionMs[1]).isAtMost(adDurationMs); + assertThat(bufferedPositionMs[1]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[1]).isEqualTo(adDurationMs - positionMs[1]); + + assertThat(windowIndex[2]).isEqualTo(0); + assertThat(isPlayingAd[2]).isFalse(); + assertThat(positionMs[2]).isGreaterThan(8000); + assertThat(bufferedPositionMs[2]).isEqualTo(contentDurationMs); + assertThat(totalBufferedDurationMs[2]).isEqualTo(contentDurationMs - positionMs[2]); + } + + @Test + public void seekTo_whilePlayingAd_correctMasking() throws Exception { + long contentDurationMs = 10_000; + long adDurationMs = 4_000; + AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0); + adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + adPlaybackState = + adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY); + long[][] durationsUs = new long[1][]; + durationsUs[0] = new long[] {C.msToUs(adDurationMs)}; + adPlaybackState = adPlaybackState.withAdDurationsUs(durationsUs); + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(contentDurationMs), + adPlaybackState)); + FakeMediaSource adsMediaSource = new FakeMediaSource(adTimeline); + int[] windowIndex = new int[] {C.INDEX_UNSET, C.INDEX_UNSET}; + long[] positionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDurationMs = new long[] {C.TIME_UNSET, C.TIME_UNSET}; + boolean[] isPlayingAd = new boolean[2]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .waitForIsLoading(true) + .waitForIsLoading(false) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 8000); + windowIndex[0] = player.getCurrentWindowIndex(); + isPlayingAd[0] = player.isPlayingAd(); + positionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + totalBufferedDurationMs[0] = player.getTotalBufferedDuration(); + } + }) + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[1] = player.getCurrentWindowIndex(); + isPlayingAd[1] = player.isPlayingAd(); + positionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + totalBufferedDurationMs[1] = player.getTotalBufferedDuration(); + } + }) + .stop() + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(adsMediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(isPlayingAd[0]).isTrue(); + assertThat(positionMs[0]).isEqualTo(0); + assertThat(bufferedPositionMs[0]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[0]).isEqualTo(adDurationMs); + + assertThat(windowIndex[1]).isEqualTo(0); + assertThat(isPlayingAd[1]).isTrue(); + assertThat(positionMs[1]).isEqualTo(0); + assertThat(bufferedPositionMs[1]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[1]).isEqualTo(adDurationMs); } @Test @@ -4535,6 +5477,80 @@ public final class ExoPlayerTest { assertThat(positionAfterSetPlayWhenReady.get()).isAtLeast(5000); } + @Test + public void setPlayWhenReady_correctPositionMasking() throws Exception { + long[] currentPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .playUntilPosition(0, 5000) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentPositionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + player.setPlayWhenReady(true); + currentPositionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + player.setPlayWhenReady(false); + currentPositionMs[2] = player.getCurrentPosition(); + bufferedPositionMs[2] = player.getBufferedPosition(); + } + }) + .play() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + assertThat(currentPositionMs[0]).isAtLeast(5000); + assertThat(currentPositionMs[1]).isEqualTo(currentPositionMs[0]); + assertThat(currentPositionMs[2]).isEqualTo(currentPositionMs[0]); + assertThat(bufferedPositionMs[0]).isGreaterThan(currentPositionMs[0]); + assertThat(bufferedPositionMs[1]).isEqualTo(bufferedPositionMs[0]); + assertThat(bufferedPositionMs[2]).isEqualTo(bufferedPositionMs[0]); + } + + @Test + public void setShuffleMode_correctPositionMasking() throws Exception { + long[] currentPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .playUntilPosition(0, 5000) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentPositionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + player.setShuffleModeEnabled(true); + currentPositionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + player.setShuffleModeEnabled(false); + currentPositionMs[2] = player.getCurrentPosition(); + bufferedPositionMs[2] = player.getBufferedPosition(); + } + }) + .play() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + assertThat(currentPositionMs[0]).isAtLeast(5000); + assertThat(currentPositionMs[1]).isEqualTo(currentPositionMs[0]); + assertThat(currentPositionMs[2]).isEqualTo(currentPositionMs[0]); + assertThat(bufferedPositionMs[0]).isGreaterThan(currentPositionMs[0]); + assertThat(bufferedPositionMs[1]).isEqualTo(bufferedPositionMs[0]); + assertThat(bufferedPositionMs[2]).isEqualTo(bufferedPositionMs[0]); + } + @Test public void setShuffleOrder_keepsCurrentPosition() throws Exception { AtomicLong positionAfterSetShuffleOrder = new AtomicLong(C.TIME_UNSET); @@ -4866,13 +5882,14 @@ public final class ExoPlayerTest { } @Test - public void setMediaSources_whenEmpty_validInitialSeek_correctMaskingWindowIndex() - throws Exception { + public void setMediaSources_whenEmpty_validInitialSeek_correctMasking() throws Exception { Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 2); MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) // Wait for initial seek to be fully handled by internal player. @@ -4883,9 +5900,13 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); // Increase current window index. player.addMediaSource(/* index= */ 0, secondMediaSource); currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .prepare() @@ -4895,11 +5916,13 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[2] = player.getCurrentWindowIndex(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); new ExoPlayerTestRunner.Builder(context) - .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .initialSeek(/* windowIndex= */ 1, 2000) .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build() @@ -4907,16 +5930,19 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 2, 2}, currentWindowIndices); + assertArrayEquals(new long[] {2000, 2000, 2000}, currentPositions); + assertArrayEquals(new long[] {2000, 2000, 2000}, bufferedPositions); } @Test - public void setMediaSources_whenEmpty_invalidInitialSeek_correctMaskingWindowIndex() - throws Exception { + public void setMediaSources_whenEmpty_invalidInitialSeek_correctMasking() throws Exception { Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) // Wait for initial seek to be fully handled by internal player. @@ -4927,9 +5953,13 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); // Increase current window index. player.addMediaSource(/* index= */ 0, secondMediaSource); currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .prepare() @@ -4939,12 +5969,14 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[2] = player.getCurrentWindowIndex(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .waitForPlaybackState(Player.STATE_ENDED) .build(); new ExoPlayerTestRunner.Builder(context) - .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .initialSeek(/* windowIndex= */ 1, 2000) .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build() @@ -4952,6 +5984,8 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {0, 1, 1}, currentWindowIndices); + assertArrayEquals(new long[] {0, 0, 0}, currentPositions); + assertArrayEquals(new long[] {0, 0, 0}, bufferedPositions); } @Test @@ -5542,10 +6576,47 @@ public final class ExoPlayerTest { } @Test - public void addMediaSources_skipSettingMediaItems_validInitialSeek_correctMaskingWindowIndex() + public void addMediaSources_whenEmptyInitialSeek_correctPeriodMasking() throws Exception { + final long[] positions = new long[2]; + Arrays.fill(positions, C.TIME_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + // Wait for initial seek to be fully handled by internal player. + .waitForPositionDiscontinuity() + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + /* index= */ 0, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + positions[0] = player.getCurrentPosition(); + positions[1] = player.getBufferedPosition(); + } + }) + .prepare() + .build(); + new ExoPlayerTestRunner.Builder(context) + .skipSettingMediaSources() + .initialSeek(/* windowIndex= */ 0, /* positionMs= */ 2000) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {2000, 2000}, positions); + } + + @Test + public void addMediaSources_skipSettingMediaItems_validInitialSeek_correctMasking() throws Exception { final int[] currentWindowIndices = new int[5]; Arrays.fill(currentWindowIndices, C.INDEX_UNSET); + final long[] currentPositions = new long[3]; + Arrays.fill(currentPositions, C.TIME_UNSET); + final long[] bufferedPositions = new long[3]; + Arrays.fill(bufferedPositions, C.TIME_UNSET); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) // Wait for initial seek to be fully handled by internal player. @@ -5556,6 +6627,9 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + // If the timeline is empty masking variables are used. + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); player.addMediaSource(/* index= */ 0, new ConcatenatingMediaSource()); currentWindowIndices[1] = player.getCurrentWindowIndex(); player.addMediaSource( @@ -5566,26 +6640,39 @@ public final class ExoPlayerTest { /* index= */ 0, new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); currentWindowIndices[3] = player.getCurrentWindowIndex(); + // With a non-empty timeline, we mask the periodId in the playback info. + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .prepare() + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[4] = player.getCurrentWindowIndex(); + // Finally original playbackInfo coming from EPII is used. + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); new ExoPlayerTestRunner.Builder(context) .skipSettingMediaSources() - .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .initialSeek(/* windowIndex= */ 1, 2000) .setActionSchedule(actionSchedule) .build() .start(/* doPrepare= */ false) .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 1, 1, 2, 2}, currentWindowIndices); + assertThat(currentPositions[0]).isEqualTo(2000); + assertThat(currentPositions[1]).isEqualTo(2000); + assertThat(currentPositions[2]).isAtLeast(2000); + assertThat(bufferedPositions[0]).isEqualTo(2000); + assertThat(bufferedPositions[1]).isEqualTo(2000); + assertThat(bufferedPositions[2]).isAtLeast(2000); } @Test @@ -5784,13 +6871,14 @@ public final class ExoPlayerTest { } @Test - public void removeMediaItems_currentItemRemoved_correctMaskingWindowIndex() throws Exception { + public void removeMediaItems_currentItemRemoved_correctMasking() throws Exception { Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .waitForPlaybackState(Player.STATE_BUFFERING) @@ -5801,9 +6889,11 @@ public final class ExoPlayerTest { // Remove the current item. currentWindowIndices[0] = player.getCurrentWindowIndex(); currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); player.removeMediaItem(/* index= */ 1); currentWindowIndices[1] = player.getCurrentWindowIndex(); currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .build(); @@ -5817,7 +6907,9 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 1}, currentWindowIndices); assertThat(currentPositions[0]).isAtLeast(5000L); + assertThat(bufferedPositions[0]).isAtLeast(5000L); assertThat(currentPositions[1]).isEqualTo(0); + assertThat(bufferedPositions[1]).isAtLeast(0); } @Test @@ -5834,6 +6926,10 @@ public final class ExoPlayerTest { Arrays.fill(currentWindowIndices, C.INDEX_UNSET); final int[] maskingPlaybackStates = new int[4]; Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + final long[] currentPositions = new long[3]; + Arrays.fill(currentPositions, C.TIME_UNSET); + final long[] bufferedPositions = new long[3]; + Arrays.fill(bufferedPositions, C.TIME_UNSET); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .waitForPlaybackState(Player.STATE_READY) @@ -5843,12 +6939,16 @@ public final class ExoPlayerTest { public void run(SimpleExoPlayer player) { // Expect the current window index to be 2 after seek. currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); player.removeMediaItem(/* index= */ 2); // Expect the current window index to be 0 // (default position of timeline after not finding subsequent period). currentWindowIndices[1] = player.getCurrentWindowIndex(); // Transition to ENDED. maskingPlaybackStates[0] = player.getPlaybackState(); + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .waitForPlaybackState(Player.STATE_ENDED) @@ -5864,6 +6964,8 @@ public final class ExoPlayerTest { currentWindowIndices[3] = player.getCurrentWindowIndex(); // Remains in ENDED. maskingPlaybackStates[1] = player.getPlaybackState(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .waitForTimelineChanged() @@ -5932,6 +7034,12 @@ public final class ExoPlayerTest { }, // buffers after set items with seek maskingPlaybackStates); assertArrayEquals(new int[] {2, 0, 0, 1, 1, 0, 0, 0, 0}, currentWindowIndices); + assertThat(currentPositions[0]).isGreaterThan(0); + assertThat(currentPositions[1]).isEqualTo(0); + assertThat(currentPositions[2]).isEqualTo(0); + assertThat(bufferedPositions[0]).isGreaterThan(0); + assertThat(bufferedPositions[1]).isEqualTo(0); + assertThat(bufferedPositions[2]).isEqualTo(0); } @Test @@ -5969,16 +7077,24 @@ public final class ExoPlayerTest { MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; final int[] maskingPlaybackState = {C.INDEX_UNSET}; + final long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) + .pause() .waitForPlaybackState(Player.STATE_BUFFERING) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 150) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); player.clearMediaItems(); currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); maskingPlaybackState[0] = player.getPlaybackState(); } }) @@ -5992,6 +7108,11 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + assertThat(currentPosition[0]).isAtLeast(150); + assertThat(currentPosition[1]).isEqualTo(0); + assertThat(bufferedPosition[0]).isAtLeast(150); + assertThat(bufferedPosition[1]).isEqualTo(0); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackState); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index a33d16e4a5..b462d8617b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -421,7 +421,7 @@ public final class AnalyticsCollectorTest { assertThat(loadingEvents).hasSize(4); assertThat(loadingEvents).containsAtLeast(period0, period0).inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) - .containsExactly(period0, period1) + .containsExactly(period0, period1, period1) .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( @@ -887,7 +887,7 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0Seq0, period0Seq0, period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) - .containsExactly(period0Seq0, period0Seq1) + .containsExactly(period0Seq0, period0Seq1, period0Seq1) .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */) diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index cc2ce99683..e5fc1d894b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -72,6 +72,7 @@ public class FakeMediaPeriod implements MediaPeriod { private boolean prepared; private long seekOffsetUs; private long discontinuityPositionUs; + private long bufferedPositionUs; /** * Constructs a FakeMediaPeriod with a single sample for each track in {@code trackGroupArray}. @@ -149,6 +150,7 @@ public class FakeMediaPeriod implements MediaPeriod { this.drmEventDispatcher = drmEventDispatcher; this.deferOnPrepared = deferOnPrepared; this.trackDataFactory = trackDataFactory; + this.bufferedPositionUs = C.TIME_END_OF_SOURCE; discontinuityPositionUs = C.TIME_UNSET; sampleStreams = new ArrayList<>(); fakePreparationLoadTaskId = LoadEventInfo.getNewId(); @@ -283,7 +285,11 @@ public class FakeMediaPeriod implements MediaPeriod { @Override public long getBufferedPositionUs() { assertThat(prepared).isTrue(); - return C.TIME_END_OF_SOURCE; + return bufferedPositionUs; + } + + public void setBufferedPositionUs(long bufferedPositionUs) { + this.bufferedPositionUs = bufferedPositionUs; } @Override @@ -293,6 +299,9 @@ public class FakeMediaPeriod implements MediaPeriod { for (SampleStream sampleStream : sampleStreams) { seekSampleStream(sampleStream, seekPositionUs); } + if (bufferedPositionUs != C.TIME_END_OF_SOURCE && seekPositionUs > bufferedPositionUs) { + bufferedPositionUs = seekPositionUs; + } return seekPositionUs; } From 820970e7672ebefadabd4cdb9658a727fdbe7a53 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 14 Jul 2020 16:32:04 +0100 Subject: [PATCH 0671/1052] Remove occurrence of sanity in AdaptiveTrackSelectionTest PiperOrigin-RevId: 321163229 --- .../exoplayer2/trackselection/AdaptiveTrackSelectionTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java index b7d06fcf15..a7a8e5a4c1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java @@ -254,7 +254,7 @@ public final class AdaptiveTrackSelectionTest { int newSize = adaptiveTrackSelection.evaluateQueueSize(/* playbackPositionUs= */ 0, queue); assertThat(newSize).isEqualTo(initialQueueSize); - // Sanity check for the comment above. + // Verify that the comment above is correct. fakeClock.advanceTime(1); newSize = adaptiveTrackSelection.evaluateQueueSize(/* playbackPositionUs= */ 0, queue); assertThat(newSize).isLessThan(initialQueueSize); From 437d1b6e9ae1e085583b3ab8e8d686d1eea32b65 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 16:53:30 +0100 Subject: [PATCH 0672/1052] Migrate usages of deprecated Player#set/getPlaybackParameters() PiperOrigin-RevId: 321166822 --- .../google/android/exoplayer2/ext/media2/PlayerWrapper.java | 4 ++-- .../android/exoplayer2/ui/PlayerNotificationManager.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java index dbf0f68f63..b04ff5f3de 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java @@ -320,12 +320,12 @@ import java.util.List; } public boolean setPlaybackSpeed(float playbackSpeed) { - player.setPlaybackParameters(new PlaybackParameters(playbackSpeed)); + player.setPlaybackSpeed(playbackSpeed); return true; } public float getPlaybackSpeed() { - return player.getPlaybackParameters().speed; + return player.getPlaybackSpeed(); } public void reset() { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java index b85bbc64b1..d0e7b0da9e 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -923,7 +923,7 @@ public class PlayerNotificationManager { *

        • The media is not {@link Player#isCurrentWindowDynamic() dynamically changing its * duration} (like for example a live stream). *
        • The media is not {@link Player#isPlayingAd() interrupted by an ad}. - *
        • The media is played at {@link Player#getPlaybackParameters() regular speed}. + *
        • The media is played at {@link Player#getPlaybackSpeed() regular speed}. *
        • The device is running at least API 21 (Lollipop). *
        * @@ -1086,7 +1086,7 @@ public class PlayerNotificationManager { && player.isPlaying() && !player.isPlayingAd() && !player.isCurrentWindowDynamic() - && player.getPlaybackParameters().speed == 1f) { + && player.getPlaybackSpeed() == 1f) { builder .setWhen(System.currentTimeMillis() - player.getContentPosition()) .setShowWhen(true) From 8eb2354e5e52d4e0d7644f3275f47cc61f507ed7 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:00:33 +0100 Subject: [PATCH 0673/1052] Replace deprecated JUnit Assertions with Truth PiperOrigin-RevId: 321168125 --- .../video/spherical/ProjectionDecoderTest.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java index b9559816d7..8cabd85fad 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java @@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; -import junit.framework.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -76,20 +75,19 @@ public final class ProjectionDecoderTest { assertThat(subMesh.textureCoords.length).isEqualTo(VERTEX_COUNT * 2); // Test first vertex - testCoordinate(FIRST_VERTEX, vertices, 0, 3); + testCoordinate(FIRST_VERTEX, vertices, /* offset= */ 0); // Test last vertex - testCoordinate(LAST_VERTEX, vertices, VERTEX_COUNT * 3 - 3, 3); + testCoordinate(LAST_VERTEX, vertices, /* offset= */ VERTEX_COUNT * 3 - 3); // Test first uv - testCoordinate(FIRST_UV, uv, 0, 2); + testCoordinate(FIRST_UV, uv, /* offset= */ 0); // Test last uv - testCoordinate(LAST_UV, uv, VERTEX_COUNT * 2 - 2, 2); + testCoordinate(LAST_UV, uv, /* offset= */ VERTEX_COUNT * 2 - 2); } /** Tests that the output coordinates match the expected. */ - private static void testCoordinate(float[] expected, float[] output, int offset, int count) { - for (int i = 0; i < count; i++) { - Assert.assertEquals(expected[i], output[i + offset]); - } + private static void testCoordinate(float[] expected, float[] output, int offset) { + float[] adjustedOutput = Arrays.copyOfRange(output, offset, offset + expected.length); + assertThat(adjustedOutput).isEqualTo(expected); } } From 5a2e04ec39ef51f8633f05fa7fcfc0b50e6839b3 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:05:20 +0100 Subject: [PATCH 0674/1052] Migrate usages of deprecated SinglePeriodTimeline constructor PiperOrigin-RevId: 321168965 --- .../android/exoplayer2/source/SinglePeriodTimelineTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java index 27b5381fef..0d1542b0c9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java @@ -97,7 +97,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* tag= */ (Object) null); + new MediaItem.Builder().setUri(Uri.EMPTY).setTag(null).build()); assertThat(timeline.getWindow(/* windowIndex= */ 0, window).tag).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).id).isNull(); @@ -117,7 +117,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - tag); + new MediaItem.Builder().setUri(Uri.EMPTY).setTag(tag).build()); assertThat(timeline.getWindow(/* windowIndex= */ 0, window).tag).isEqualTo(tag); } From 8ae04bcfee3f2c23496bc11ecd986ff7527ec036 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:08:32 +0100 Subject: [PATCH 0675/1052] Migrate usages of deprecated Window#tag PiperOrigin-RevId: 321169585 --- .../android/exoplayer2/source/SilenceMediaSource.java | 3 ++- .../android/exoplayer2/source/SingleSampleMediaSource.java | 3 ++- .../android/exoplayer2/source/SinglePeriodTimelineTest.java | 4 ++++ .../google/android/exoplayer2/testutil/TimelineAsserts.java | 6 +++++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index ed5db6634f..535e917299 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -55,7 +55,8 @@ public final class SilenceMediaSource extends BaseMediaSource { /** * Sets a tag for the media source which will be published in the {@link * com.google.android.exoplayer2.Timeline} of the source as {@link - * com.google.android.exoplayer2.Timeline.Window#tag}. + * com.google.android.exoplayer2.MediaItem.PlaybackProperties#tag + * Window#mediaItem.playbackProperties.tag}. * * @param tag A tag for the media source. * @return This factory, for convenience. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index e18e1738a2..ab63ed83e6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -80,7 +80,8 @@ public final class SingleSampleMediaSource extends BaseMediaSource { /** * Sets a tag for the media source which will be published in the {@link Timeline} of the source - * as {@link Timeline.Window#tag}. + * as {@link com.google.android.exoplayer2.MediaItem.PlaybackProperties#tag + * Window#mediaItem.playbackProperties.tag}. * * @param tag A tag for the media source. * @return This factory, for convenience. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java index 0d1542b0c9..4fce17e336 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java @@ -89,6 +89,7 @@ public final class SinglePeriodTimelineTest { } @Test + @SuppressWarnings("deprecation") // Testing deprecated Window.tag is still populated correctly. public void setNullTag_returnsNullTag_butUsesDefaultUid() { SinglePeriodTimeline timeline = new SinglePeriodTimeline( @@ -100,6 +101,8 @@ public final class SinglePeriodTimelineTest { new MediaItem.Builder().setUri(Uri.EMPTY).setTag(null).build()); assertThat(timeline.getWindow(/* windowIndex= */ 0, window).tag).isNull(); + assertThat(timeline.getWindow(/* windowIndex= */ 0, window).mediaItem.playbackProperties.tag) + .isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).id).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).id).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).uid).isNull(); @@ -108,6 +111,7 @@ public final class SinglePeriodTimelineTest { } @Test + @SuppressWarnings("deprecation") // Testing deprecated Window.tag is still populated correctly. public void getWindow_setsTag() { Object tag = new Object(); SinglePeriodTimeline timeline = diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java index 7d31bc7ee3..efcca8cd92 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java @@ -18,11 +18,13 @@ package com.google.android.exoplayer2.testutil; import static com.google.common.truth.Truth.assertThat; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; import org.checkerframework.checker.nullness.compatqual.NullableType; /** Unit test for {@link Timeline}. */ @@ -57,7 +59,9 @@ public final class TimelineAsserts { for (int i = 0; i < timeline.getWindowCount(); i++) { timeline.getWindow(i, window); if (expectedWindowTags[i] != null) { - assertThat(window.tag).isEqualTo(expectedWindowTags[i]); + MediaItem.PlaybackProperties playbackProperties = window.mediaItem.playbackProperties; + assertThat(playbackProperties).isNotNull(); + assertThat(Util.castNonNull(playbackProperties).tag).isEqualTo(expectedWindowTags[i]); } } } From 2f19b63ca0eef13c2581be1cb738d698a6bf09d3 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:31:46 +0100 Subject: [PATCH 0676/1052] Add package-info files to packages that only exist in tests This doesn't affect the nullness checker or Kotlin, but it does make weird warnings appear in Android Studio. It seems mildly preferable to have the same spurious warnings in these files that we have in other tests, rather than different spurious warnings. PiperOrigin-RevId: 321173760 --- .../exoplayer2/castdemo/package-info.java | 19 +++++++++++++++++++ .../exoplayer2/gldemo/package-info.java | 19 +++++++++++++++++++ .../android/exoplayer2/demo/package-info.java | 19 +++++++++++++++++++ .../exoplayer2/surfacedemo/package-info.java | 19 +++++++++++++++++++ .../playbacktests/gts/package-info.java | 19 +++++++++++++++++++ .../playbacktests/hls/package-info.java | 19 +++++++++++++++++++ .../playbacktests/package-info.java | 19 +++++++++++++++++++ 7 files changed, 133 insertions(+) create mode 100644 demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java create mode 100644 demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java create mode 100644 demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java create mode 100644 demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java create mode 100644 playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/package-info.java create mode 100644 playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/hls/package-info.java create mode 100644 playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/package-info.java diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java new file mode 100644 index 0000000000..70e2af79df --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.castdemo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java new file mode 100644 index 0000000000..59ad052449 --- /dev/null +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.gldemo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java new file mode 100644 index 0000000000..cc22be27e0 --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.demo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java new file mode 100644 index 0000000000..0f632a6e3c --- /dev/null +++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.surfacedemo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/package-info.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/package-info.java new file mode 100644 index 0000000000..801f3c4bb5 --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.playbacktests.gts; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/hls/package-info.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/hls/package-info.java new file mode 100644 index 0000000000..f971323c6e --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/hls/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.playbacktests.hls; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/package-info.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/package-info.java new file mode 100644 index 0000000000..4131ced72a --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.playbacktests; + +import com.google.android.exoplayer2.util.NonNullApi; From 5c4b8085a0fe7c3c23ff6d770b8cd25ae0f886a8 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:37:06 +0100 Subject: [PATCH 0677/1052] Migrate usages of DefaultDrmSessionManager constructor to Builder PiperOrigin-RevId: 321174738 --- .../drm/DefaultDrmSessionManager.java | 1 + .../playbacktests/gts/DashTestRunner.java | 44 +++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index db98e9401b..890c2dac28 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -326,6 +326,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { * Default is false. * @deprecated Use {@link Builder} instead. */ + @SuppressWarnings("deprecation") @Deprecated public DefaultDrmSessionManager( UUID uuid, diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java index ea745ed257..c8c4f98a85 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java @@ -262,29 +262,29 @@ import java.util.List; if (widevineLicenseUrl == null) { return DrmSessionManager.getDummyDrmSessionManager(); } - try { - MediaDrmCallback drmCallback = new HttpMediaDrmCallback(widevineLicenseUrl, - new DefaultHttpDataSourceFactory(userAgent)); - FrameworkMediaDrm frameworkMediaDrm = FrameworkMediaDrm.newInstance(WIDEVINE_UUID); - DefaultDrmSessionManager drmSessionManager = - new DefaultDrmSessionManager( - C.WIDEVINE_UUID, - frameworkMediaDrm, - drmCallback, - /* keyRequestParameters= */ null, - /* multiSession= */ false, - DefaultDrmSessionManager.INITIAL_DRM_REQUEST_RETRY_COUNT); - if (!useL1Widevine) { - frameworkMediaDrm.setPropertyString(SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3); - } - if (offlineLicenseKeySetId != null) { - drmSessionManager.setMode(DefaultDrmSessionManager.MODE_PLAYBACK, - offlineLicenseKeySetId); - } - return drmSessionManager; - } catch (UnsupportedDrmException e) { - throw new IllegalStateException(e); + MediaDrmCallback drmCallback = + new HttpMediaDrmCallback(widevineLicenseUrl, new DefaultHttpDataSourceFactory(userAgent)); + DefaultDrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + C.WIDEVINE_UUID, + uuid -> { + try { + FrameworkMediaDrm drm = FrameworkMediaDrm.newInstance(WIDEVINE_UUID); + if (!useL1Widevine) { + drm.setPropertyString(SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3); + } + return drm; + } catch (UnsupportedDrmException e) { + throw new IllegalStateException(e); + } + }) + .build(drmCallback); + + if (offlineLicenseKeySetId != null) { + drmSessionManager.setMode(DefaultDrmSessionManager.MODE_PLAYBACK, offlineLicenseKeySetId); } + return drmSessionManager; } @Override From bf5e6c7862ae418895b2b6b26fae9c28aa662ee8 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:42:23 +0100 Subject: [PATCH 0678/1052] Pass startPositionUs into Renderer.replaceStream Plumb this down into BaseRenderer.onStreamChanged and use it when deciding whether to render the first frame of a new period. PiperOrigin-RevId: 321175627 --- RELEASENOTES.md | 2 + .../android/exoplayer2/BaseRenderer.java | 38 +++++----- .../exoplayer2/ExoPlayerImplInternal.java | 6 +- .../android/exoplayer2/NoSampleRenderer.java | 6 +- .../google/android/exoplayer2/Renderer.java | 17 +++-- .../mediacodec/MediaCodecRenderer.java | 37 ++++++++-- .../exoplayer2/metadata/MetadataRenderer.java | 2 +- .../android/exoplayer2/text/TextRenderer.java | 2 +- .../video/DecoderVideoRenderer.java | 5 +- .../video/MediaCodecVideoRenderer.java | 5 +- .../video/spherical/CameraMotionRenderer.java | 2 +- .../android/exoplayer2/ExoPlayerTest.java | 4 +- .../audio/DecoderAudioRendererTest.java | 1 + .../audio/MediaCodecAudioRendererTest.java | 3 + .../metadata/MetadataRendererTest.java | 1 + .../video/DecoderVideoRendererTest.java | 67 +++++------------ .../video/MediaCodecVideoRendererTest.java | 71 ++++++------------- .../testutil/FakeVideoRenderer.java | 11 +-- 18 files changed, 133 insertions(+), 147 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 47babde849..348357937f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -96,6 +96,8 @@ ([#7590](https://github.com/google/ExoPlayer/issues/7590)). * Remove `AdaptiveTrackSelection.minTimeBetweenBufferReevaluationMs` parameter ([#7582](https://github.com/google/ExoPlayer/issues/7582)). + * Distinguish between `offsetUs` and `startPositionUs` when passing new + `SampleStreams` to `Renderers`. * Video: Pass frame rate hint to `Surface.setFrameRate` on Android R devices. * Track selection: * Add `Player.getTrackSelector`. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java index 879daa603f..fa161a35c9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -36,7 +36,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { private SampleStream stream; private Format[] streamFormats; private long streamOffsetUs; - private long startPositionUs; + private long lastResetPositionUs; private long readingPositionUs; private boolean streamIsFinal; private boolean throwRendererExceptionIsExecuting; @@ -85,14 +85,15 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); this.configuration = configuration; state = STATE_ENABLED; - startPositionUs = positionUs; + lastResetPositionUs = positionUs; onEnabled(joining, mayRenderStartOfStream); - replaceStream(formats, stream, offsetUs); + replaceStream(formats, stream, startPositionUs, offsetUs); onPositionReset(positionUs, joining); } @@ -104,14 +105,15 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { } @Override - public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + public final void replaceStream( + Format[] formats, SampleStream stream, long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(!streamIsFinal); this.stream = stream; - readingPositionUs = offsetUs; + readingPositionUs = startPositionUs; streamFormats = formats; streamOffsetUs = offsetUs; - onStreamChanged(formats, offsetUs); + onStreamChanged(formats, startPositionUs, offsetUs); } @Override @@ -148,7 +150,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { @Override public final void resetPosition(long positionUs) throws ExoPlaybackException { streamIsFinal = false; - startPositionUs = positionUs; + lastResetPositionUs = positionUs; readingPositionUs = positionUs; onPositionReset(positionUs, false); } @@ -218,24 +220,26 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { *

        The default implementation is a no-op. * * @param formats The enabled formats. + * @param startPositionUs The start position of the new stream in renderer time (microseconds). * @param offsetUs The offset that will be added to the timestamps of buffers read via {@link * #readSource(FormatHolder, DecoderInputBuffer, boolean)} so that decoder input buffers have * monotonically increasing timestamps. * @throws ExoPlaybackException If an error occurs. */ - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { // Do nothing. } /** - * Called when the position is reset. This occurs when the renderer is enabled after - * {@link #onStreamChanged(Format[], long)} has been called, and also when a position - * discontinuity is encountered. - *

        - * After a position reset, the renderer's {@link SampleStream} is guaranteed to provide samples + * Called when the position is reset. This occurs when the renderer is enabled after {@link + * #onStreamChanged(Format[], long, long)} has been called, and also when a position discontinuity + * is encountered. + * + *

        After a position reset, the renderer's {@link SampleStream} is guaranteed to provide samples * starting from a key frame. - *

        - * The default implementation is a no-op. + * + *

        The default implementation is a no-op. * * @param positionUs The new playback position in microseconds. * @param joining Whether this renderer is being enabled to join an ongoing playback. @@ -289,8 +293,8 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { * Returns the position passed to the most recent call to {@link #enable} or {@link * #resetPosition}. */ - protected final long getStartPositionUs() { - return startPositionUs; + protected final long getLastResetPositionUs() { + return lastResetPositionUs; } /** Returns a clear {@link FormatHolder}. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index b693b4ca70..cb0aa22a38 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -1873,7 +1873,10 @@ import java.util.concurrent.atomic.AtomicBoolean; // The renderer stream is not final, so we can replace the sample streams immediately. Format[] formats = getFormats(newTrackSelectorResult.selections.get(i)); renderer.replaceStream( - formats, readingPeriodHolder.sampleStreams[i], readingPeriodHolder.getRendererOffset()); + formats, + readingPeriodHolder.sampleStreams[i], + readingPeriodHolder.getStartPositionRendererTime(), + readingPeriodHolder.getRendererOffset()); } else if (renderer.isEnded()) { // The renderer has finished playback, so we can disable it now. disableRenderer(renderer); @@ -2128,6 +2131,7 @@ import java.util.concurrent.atomic.AtomicBoolean; rendererPositionUs, joining, mayRenderStartOfStream, + periodHolder.getStartPositionRendererTime(), periodHolder.getRendererOffset()); renderer.handleMessage( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java index 46961d027f..fd5f0431e1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java @@ -68,13 +68,14 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); this.configuration = configuration; state = STATE_ENABLED; onEnabled(joining); - replaceStream(formats, stream, offsetUs); + replaceStream(formats, stream, startPositionUs, offsetUs); onPositionReset(positionUs, joining); } @@ -86,7 +87,8 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities } @Override - public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + public final void replaceStream( + Format[] formats, SampleStream stream, long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(!streamIsFinal); this.stream = stream; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index fdaa7d5cce..96b72d09fc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -292,6 +292,7 @@ public interface Renderer extends PlayerMessage.Target { * @param joining Whether this renderer is being enabled to join an ongoing playback. * @param mayRenderStartOfStream Whether this renderer is allowed to render the start of the * stream even if the state is not {@link #STATE_STARTED} yet. + * @param startPositionUs The start position of the stream in renderer time (microseconds). * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} before * they are rendered. * @throws ExoPlaybackException If an error occurs. @@ -303,6 +304,7 @@ public interface Renderer extends PlayerMessage.Target { long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException; @@ -319,17 +321,18 @@ public interface Renderer extends PlayerMessage.Target { /** * Replaces the {@link SampleStream} from which samples will be consumed. - *

        - * This method may be called when the renderer is in the following states: - * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + *

        This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. * * @param formats The enabled formats. * @param stream The {@link SampleStream} from which the renderer should consume. + * @param startPositionUs The start position of the new stream in renderer time (microseconds). * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} before * they are rendered. * @throws ExoPlaybackException If an error occurs. */ - void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + void replaceStream(Format[] formats, SampleStream stream, long startPositionUs, long offsetUs) throws ExoPlaybackException; /** Returns the {@link SampleStream} being consumed, or null if the renderer is disabled. */ @@ -345,7 +348,7 @@ public interface Renderer extends PlayerMessage.Target { boolean hasReadStreamToEnd(); /** - * Returns the playback position up to which the renderer has read samples from the current {@link + * Returns the renderer time up to which the renderer has read samples from the current {@link * SampleStream}, in microseconds, or {@link C#TIME_END_OF_SOURCE} if the renderer has read the * current {@link SampleStream} to the end. * @@ -418,8 +421,8 @@ public interface Renderer extends PlayerMessage.Target { *

        The renderer may also render the very start of the media at the current position (e.g. the * first frame of a video stream) while still in the {@link #STATE_ENABLED} state, unless it's the * initial start of the media after calling {@link #enable(RendererConfiguration, Format[], - * SampleStream, long, boolean, boolean, long)} with {@code mayRenderStartOfStream} set to {@code - * false}. + * SampleStream, long, boolean, boolean, long, long)} with {@code mayRenderStartOfStream} set to + * {@code false}. * *

        This method should return quickly, and should not block if the renderer is unable to make * useful progress. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index a893255736..8ca4261afc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import android.annotation.TargetApi; import android.media.MediaCodec; import android.media.MediaCodec.CodecException; @@ -350,6 +352,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private final TimedValueQueue formatQueue; private final ArrayList decodeOnlyPresentationTimestamps; private final MediaCodec.BufferInfo outputBufferInfo; + private final long[] pendingOutputStreamStartPositionsUs; private final long[] pendingOutputStreamOffsetsUs; private final long[] pendingOutputStreamSwitchTimesUs; @@ -406,6 +409,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @MediaCodecOperationMode private int mediaCodecOperationMode; @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; + private long outputStreamStartPositionUs; private long outputStreamOffsetUs; private int pendingOutputStreamOffsetCount; private boolean receivedOutputMediaFormatChange; @@ -438,8 +442,10 @@ public abstract class MediaCodecRenderer extends BaseRenderer { operatingRate = 1f; renderTimeLimitMs = C.TIME_UNSET; mediaCodecOperationMode = OPERATION_MODE_SYNCHRONOUS; + pendingOutputStreamStartPositionsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamSwitchTimesUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; + outputStreamStartPositionUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET; bypassBatchBuffer = new BatchBuffer(); resetCodecStateForRelease(); @@ -676,9 +682,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { - if (outputStreamOffsetUs == C.TIME_UNSET) { - outputStreamOffsetUs = offsetUs; + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { + if (this.outputStreamOffsetUs == C.TIME_UNSET) { + checkState(this.outputStreamStartPositionUs == C.TIME_UNSET); + this.outputStreamStartPositionUs = startPositionUs; + this.outputStreamOffsetUs = offsetUs; } else { if (pendingOutputStreamOffsetCount == pendingOutputStreamOffsetsUs.length) { Log.w( @@ -688,6 +697,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } else { pendingOutputStreamOffsetCount++; } + pendingOutputStreamStartPositionsUs[pendingOutputStreamOffsetCount - 1] = startPositionUs; pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1] = offsetUs; pendingOutputStreamSwitchTimesUs[pendingOutputStreamOffsetCount - 1] = largestQueuedPresentationTimeUs; @@ -713,6 +723,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { formatQueue.clear(); if (pendingOutputStreamOffsetCount != 0) { outputStreamOffsetUs = pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1]; + outputStreamStartPositionUs = + pendingOutputStreamStartPositionsUs[pendingOutputStreamOffsetCount - 1]; pendingOutputStreamOffsetCount = 0; } } @@ -730,6 +742,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onDisabled() { inputFormat = null; + outputStreamStartPositionUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET; pendingOutputStreamOffsetCount = 0; if (sourceDrmSession != null || codecDrmSession != null) { @@ -1562,8 +1575,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected void onProcessedOutputBuffer(long presentationTimeUs) { while (pendingOutputStreamOffsetCount != 0 && presentationTimeUs >= pendingOutputStreamSwitchTimesUs[0]) { + outputStreamStartPositionUs = pendingOutputStreamStartPositionsUs[0]; outputStreamOffsetUs = pendingOutputStreamOffsetsUs[0]; pendingOutputStreamOffsetCount--; + System.arraycopy( + pendingOutputStreamStartPositionsUs, + /* srcPos= */ 1, + pendingOutputStreamStartPositionsUs, + /* destPos= */ 0, + pendingOutputStreamOffsetCount); System.arraycopy( pendingOutputStreamOffsetsUs, /* srcPos= */ 1, @@ -1953,6 +1973,13 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return largestQueuedPresentationTimeUs; } + /** + * Returns the start position of the output {@link SampleStream}, in renderer time microseconds. + */ + protected final long getOutputStreamStartPositionUs() { + return outputStreamStartPositionUs; + } + /** * Returns the offset that should be subtracted from {@code bufferPresentationTimeUs} in {@link * #processOutputBuffer(long, long, MediaCodec, ByteBuffer, int, int, int, long, boolean, boolean, @@ -2088,7 +2115,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { BatchBuffer batchBuffer = bypassBatchBuffer; // Let's process the pending buffer if any. - Assertions.checkState(!outputStreamEnded); + checkState(!outputStreamEnded); if (!batchBuffer.isEmpty()) { // Optimisation: Do not process buffer if empty. if (processOutputBuffer( positionUs, @@ -2127,7 +2154,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } // Now refill the empty buffer for the next iteration. - Assertions.checkState(!inputStreamEnded); + checkState(!inputStreamEnded); FormatHolder formatHolder = getFormatHolder(); boolean formatChange = readBatchFromSource(formatHolder, batchBuffer); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index 02e55070b2..dd698691b4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -110,7 +110,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { decoder = decoderFactory.createDecoder(formats[0]); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java index 92e2ffc7c1..a85fdfd037 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java @@ -141,7 +141,7 @@ public final class TextRenderer extends BaseRenderer implements Callback { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { streamFormat = formats[0]; if (decoder != null) { decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java index ddf5c4b98e..9a1699e6c5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java @@ -300,12 +300,13 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { // TODO: This shouldn't just update the output stream offset as long as there are still buffers // of the previous stream in the decoder. It should also make sure to render the first frame of // the next stream if the playback position reached the new stream. outputStreamOffsetUs = offsetUs; - super.onStreamChanged(formats, offsetUs); + super.onStreamChanged(formats, startPositionUs, offsetUs); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index a354c21f29..1624fd15ac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -774,8 +774,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { initialPositionUs = positionUs; } - long outputStreamOffsetUs = getOutputStreamOffsetUs(); - long presentationTimeUs = bufferPresentationTimeUs - outputStreamOffsetUs; + long presentationTimeUs = bufferPresentationTimeUs - getOutputStreamOffsetUs(); if (isDecodeOnlyBuffer && !isLastBuffer) { skipOutputBuffer(codec, bufferIndex, presentationTimeUs); @@ -803,7 +802,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { // Don't force output until we joined and the position reached the current stream. boolean forceRenderOutputBuffer = joiningDeadlineMs == C.TIME_UNSET - && positionUs >= outputStreamOffsetUs + && positionUs >= getOutputStreamStartPositionUs() && (shouldRenderFirstFrame || (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs))); if (forceRenderOutputBuffer) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java index 5dd378101d..75902c0f14 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java @@ -73,7 +73,7 @@ public final class CameraMotionRenderer extends BaseRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { this.offsetUs = offsetUs; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 947a2975d0..2fe26bddbb 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -7245,7 +7245,7 @@ public final class ExoPlayerTest { FakeRenderer audioRenderer = new FakeRenderer(C.TRACK_TYPE_AUDIO) { @Override - protected void onStreamChanged(Format[] formats, long offsetUs) + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) throws ExoPlaybackException { // Fail when changing streams. This will happen during the period transition. throw createRendererException( @@ -7758,7 +7758,7 @@ public final class ExoPlayerTest { boolean pendingFirstBufferTime = false; @Override - protected void onStreamChanged(Format[] formats, long offsetUs) { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { rendererStreamOffsetsUs.add(offsetUs); pendingFirstBufferTime = true; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java index f6e3ac941d..072ec2433b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java @@ -116,6 +116,7 @@ public class DecoderAudioRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs= */ 0); audioRenderer.setCurrentStreamFinal(); when(mockAudioSink.isEnded()).thenReturn(true); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java index f4c1e12845..081b591b1f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -127,6 +127,7 @@ public class MediaCodecAudioRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecAudioRenderer.start(); @@ -181,6 +182,7 @@ public class MediaCodecAudioRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecAudioRenderer.start(); @@ -248,6 +250,7 @@ public class MediaCodecAudioRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); exceptionThrowingRenderer.start(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java index 1a6b6e834d..796f56becf 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java @@ -154,6 +154,7 @@ public class MetadataRendererTest { ImmutableList.of( FakeSampleStreamItem.sample(/* timeUs= */ 0, /* flags= */ 0, input), FakeSampleStreamItem.END_OF_STREAM_ITEM)), + /* startPositionUs= */ 0L, /* offsetUs= */ 0L); renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); // Read the format renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); // Read the data diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java index 71b32af98b..b9dc866371 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java @@ -199,6 +199,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0L, /* offsetUs */ 0); for (int i = 0; i < 10; i++) { renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -227,6 +228,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); for (int i = 0; i < 10; i++) { renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -254,6 +256,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); renderer.start(); for (int i = 0; i < 10; i++) { @@ -268,7 +271,7 @@ public final class DecoderVideoRendererTest { // TODO: Fix rendering of first frame at stream transition. @Ignore @Test - public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { + public void replaceStream_rendersFirstFrameOnlyAfterStartPosition() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( /* mediaSourceEventDispatcher= */ null, @@ -284,7 +287,7 @@ public final class DecoderVideoRendererTest { new DrmSessionEventListener.EventDispatcher(), /* initialFormat= */ H264_FORMAT, ImmutableList.of( - oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); + oneByteSample(/* timeUs= */ 1_000_000), FakeSampleStreamItem.END_OF_STREAM_ITEM)); renderer.enable( RendererConfiguration.DEFAULT, new Format[] {H264_FORMAT}, @@ -292,67 +295,31 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); - renderer.start(); boolean replacedStream = false; - for (int i = 0; i <= 10; i++) { + // Render until just before the start position of the second stream + for (int i = 0; i < 5; i++) { renderer.render(/* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); if (!replacedStream && renderer.hasReadStreamToEnd()) { - renderer.replaceStream(new Format[] {H264_FORMAT}, fakeSampleStream2, /* offsetUs= */ 100); - replacedStream = true; - } - // Ensure pending messages are delivered. - ShadowLooper.idleMainLooper(); - } - - verify(eventListener, times(2)).onRenderedFirstFrame(any()); - } - - // TODO: Fix rendering of first frame at stream transition. - @Ignore - @Test - public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { - FakeSampleStream fakeSampleStream1 = - new FakeSampleStream( - /* mediaSourceEventDispatcher= */ null, - DrmSessionManager.DUMMY, - new DrmSessionEventListener.EventDispatcher(), - /* initialFormat= */ H264_FORMAT, - ImmutableList.of( - oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); - FakeSampleStream fakeSampleStream2 = - new FakeSampleStream( - /* mediaSourceEventDispatcher= */ null, - DrmSessionManager.DUMMY, - new DrmSessionEventListener.EventDispatcher(), - /* initialFormat= */ H264_FORMAT, - ImmutableList.of( - oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); - renderer.enable( - RendererConfiguration.DEFAULT, - new Format[] {H264_FORMAT}, - fakeSampleStream1, - /* positionUs= */ 0, - /* joining= */ false, - /* mayRenderStartOfStream= */ true, - /* offsetUs */ 0); - - boolean replacedStream = false; - for (int i = 0; i < 10; i++) { - renderer.render(/* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); - if (!replacedStream && renderer.hasReadStreamToEnd()) { - renderer.replaceStream(new Format[] {H264_FORMAT}, fakeSampleStream2, /* offsetUs= */ 100); + renderer.replaceStream( + new Format[] {H264_FORMAT}, + fakeSampleStream2, + /* startPositionUs= */ 50, + /* offsetUs= */ 100); replacedStream = true; } // Ensure pending messages are delivered. ShadowLooper.idleMainLooper(); } + // Expect only the first frame of the first stream to have been rendered. verify(eventListener).onRenderedFirstFrame(any()); - // Render to streamOffsetUs and verify the new first frame gets rendered. - renderer.render(/* positionUs= */ 100, SystemClock.elapsedRealtime() * 1000); + // Render to the start position of the stream and verify the new first frame gets rendered (even + // though its sampleTimeUs is far in the future). + renderer.render(/* positionUs= */ 50, SystemClock.elapsedRealtime() * 1000); verify(eventListener, times(2)).onRenderedFirstFrame(any()); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java index 2b6b6369cc..5ed673bf7c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java @@ -140,6 +140,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecVideoRenderer.start(); @@ -171,6 +172,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecVideoRenderer.setCurrentStreamFinal(); mediaCodecVideoRenderer.start(); @@ -212,6 +214,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecVideoRenderer.start(); mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -256,6 +259,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecVideoRenderer.start(); @@ -291,6 +295,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); for (int i = 0; i < 10; i++) { mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -317,6 +322,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); for (int i = 0; i < 10; i++) { mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -342,6 +348,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecVideoRenderer.start(); for (int i = 0; i < 10; i++) { @@ -352,7 +359,7 @@ public class MediaCodecVideoRendererTest { } @Test - public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { + public void replaceStream_rendersFirstFrameOnlyAfterStartPosition() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( /* mediaSourceEventDispatcher= */ null, @@ -369,7 +376,7 @@ public class MediaCodecVideoRendererTest { new DrmSessionEventListener.EventDispatcher(), /* initialFormat= */ VIDEO_H264, ImmutableList.of( - oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 1_000_000, C.BUFFER_FLAG_KEY_FRAME), FakeSampleStreamItem.END_OF_STREAM_ITEM)); mediaCodecVideoRenderer.enable( RendererConfiguration.DEFAULT, @@ -378,67 +385,30 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); - mediaCodecVideoRenderer.start(); boolean replacedStream = false; - for (int i = 0; i <= 10; i++) { + // Render until just before the start position of the second stream + for (int i = 0; i < 5; i++) { mediaCodecVideoRenderer.render( /* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); if (!replacedStream && mediaCodecVideoRenderer.hasReadStreamToEnd()) { mediaCodecVideoRenderer.replaceStream( - new Format[] {VIDEO_H264}, fakeSampleStream2, /* offsetUs= */ 100); - replacedStream = true; - } - } - - verify(eventListener, times(2)).onRenderedFirstFrame(any()); - } - - @Test - public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { - FakeSampleStream fakeSampleStream1 = - new FakeSampleStream( - /* mediaSourceEventDispatcher= */ null, - DrmSessionManager.DUMMY, - new DrmSessionEventListener.EventDispatcher(), - /* initialFormat= */ VIDEO_H264, - ImmutableList.of( - oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM)); - FakeSampleStream fakeSampleStream2 = - new FakeSampleStream( - /* mediaSourceEventDispatcher= */ null, - DrmSessionManager.DUMMY, - new DrmSessionEventListener.EventDispatcher(), - /* initialFormat= */ VIDEO_H264, - ImmutableList.of( - oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM)); - mediaCodecVideoRenderer.enable( - RendererConfiguration.DEFAULT, - new Format[] {VIDEO_H264}, - fakeSampleStream1, - /* positionUs= */ 0, - /* joining= */ false, - /* mayRenderStartOfStream= */ true, - /* offsetUs */ 0); - - boolean replacedStream = false; - for (int i = 0; i < 10; i++) { - mediaCodecVideoRenderer.render( - /* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); - if (!replacedStream && mediaCodecVideoRenderer.hasReadStreamToEnd()) { - mediaCodecVideoRenderer.replaceStream( - new Format[] {VIDEO_H264}, fakeSampleStream2, /* offsetUs= */ 100); + new Format[] {VIDEO_H264}, + fakeSampleStream2, + /* startPositionUs= */ 50, + /* offsetUs= */ 100); replacedStream = true; } } + // Expect only the first frame of the first stream to have been rendered. verify(eventListener).onRenderedFirstFrame(any()); - // Render to streamOffsetUs and verify the new first frame gets rendered. - mediaCodecVideoRenderer.render(/* positionUs= */ 100, SystemClock.elapsedRealtime() * 1000); + // Render to the start position of the stream and verify the new first frame gets rendered (even + // though its sampleTimeUs is far in the future). + mediaCodecVideoRenderer.render(/* positionUs= */ 50, SystemClock.elapsedRealtime() * 1000); verify(eventListener, times(2)).onRenderedFirstFrame(any()); } @@ -473,6 +443,7 @@ public class MediaCodecVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); mediaCodecVideoRenderer.setCurrentStreamFinal(); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java index c7163cf553..9cb2c80d8d 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeVideoRenderer.java @@ -33,7 +33,7 @@ public class FakeVideoRenderer extends FakeRenderer { private final VideoRendererEventListener.EventDispatcher eventDispatcher; private final DecoderCounters decoderCounters; private @MonotonicNonNull Format format; - private long streamOffsetUs; + private long startPositionUs; private boolean renderedFirstFrameAfterReset; private boolean mayRenderFirstFrameAfterEnableIfNotStarted; private boolean renderedFirstFrameAfterEnable; @@ -54,9 +54,10 @@ public class FakeVideoRenderer extends FakeRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { - super.onStreamChanged(formats, offsetUs); - streamOffsetUs = offsetUs; + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { + super.onStreamChanged(formats, startPositionUs, offsetUs); + this.startPositionUs = startPositionUs; if (renderedFirstFrameAfterReset) { renderedFirstFrameAfterReset = false; } @@ -101,7 +102,7 @@ public class FakeVideoRenderer extends FakeRenderer { !renderedFirstFrameAfterEnable ? (getState() == Renderer.STATE_STARTED || mayRenderFirstFrameAfterEnableIfNotStarted) : !renderedFirstFrameAfterReset; - shouldProcess |= shouldRenderFirstFrame && playbackPositionUs >= streamOffsetUs; + shouldProcess |= shouldRenderFirstFrame && playbackPositionUs >= startPositionUs; if (shouldProcess && !renderedFirstFrameAfterReset) { @MonotonicNonNull Format format = Assertions.checkNotNull(this.format); eventDispatcher.videoSizeChanged( From 8cd4afcdeef73d156e39e269a260d35bb172090c Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 14 Jul 2020 17:50:42 +0100 Subject: [PATCH 0679/1052] Remove the generic EventDispatcher from util directory It's only used in BandwidthMeter so inline it there. PiperOrigin-RevId: 321177126 --- .../exoplayer2/upstream/BandwidthMeter.java | 58 ++++++++++ .../upstream/DefaultBandwidthMeter.java | 9 +- .../exoplayer2/util/EventDispatcher.java | 100 ------------------ 3 files changed, 62 insertions(+), 105 deletions(-) delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java index 853a9af526..8fefb50a96 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java @@ -17,6 +17,8 @@ package com.google.android.exoplayer2.upstream; import android.os.Handler; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Assertions; +import java.util.concurrent.CopyOnWriteArrayList; /** * Provides estimates of the currently available bandwidth. @@ -42,6 +44,62 @@ public interface BandwidthMeter { * @param bitrateEstimate The estimated bitrate in bits/sec. */ void onBandwidthSample(int elapsedMs, long bytesTransferred, long bitrateEstimate); + + /** Event dispatcher which allows listener registration. */ + final class EventDispatcher { + + private final CopyOnWriteArrayList listeners; + + /** Creates an event dispatcher. */ + public EventDispatcher() { + listeners = new CopyOnWriteArrayList<>(); + } + + /** Adds a listener to the event dispatcher. */ + public void addListener(Handler handler, BandwidthMeter.EventListener eventListener) { + Assertions.checkArgument(handler != null && eventListener != null); + removeListener(eventListener); + listeners.add(new HandlerAndListener(handler, eventListener)); + } + + /** Removes a listener from the event dispatcher. */ + public void removeListener(BandwidthMeter.EventListener eventListener) { + for (HandlerAndListener handlerAndListener : listeners) { + if (handlerAndListener.listener == eventListener) { + handlerAndListener.release(); + listeners.remove(handlerAndListener); + } + } + } + + public void bandwidthSample(int elapsedMs, long bytesTransferred, long bitrateEstimate) { + for (HandlerAndListener handlerAndListener : listeners) { + if (!handlerAndListener.released) { + handlerAndListener.handler.post( + () -> + handlerAndListener.listener.onBandwidthSample( + elapsedMs, bytesTransferred, bitrateEstimate)); + } + } + } + + private static final class HandlerAndListener { + + private final Handler handler; + private final BandwidthMeter.EventListener listener; + + private boolean released; + + public HandlerAndListener(Handler handler, BandwidthMeter.EventListener eventListener) { + this.handler = handler; + this.listener = eventListener; + } + + public void release() { + released = true; + } + } + } } /** Returns the estimated bitrate. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index 143a2469ab..66aec96e89 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -25,9 +25,9 @@ import android.os.Looper; import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.BandwidthMeter.EventListener.EventDispatcher; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.EventDispatcher; import com.google.android.exoplayer2.util.SlidingPercentile; import com.google.android.exoplayer2.util.Util; import java.lang.ref.WeakReference; @@ -256,7 +256,7 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList @Nullable private final Context context; private final SparseArray initialBitrateEstimates; - private final EventDispatcher eventDispatcher; + private final EventDispatcher eventDispatcher; private final SlidingPercentile slidingPercentile; private final Clock clock; @@ -292,7 +292,7 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList boolean resetOnNetworkTypeChange) { this.context = context == null ? null : context.getApplicationContext(); this.initialBitrateEstimates = initialBitrateEstimates; - this.eventDispatcher = new EventDispatcher<>(); + this.eventDispatcher = new EventDispatcher(); this.slidingPercentile = new SlidingPercentile(maxWeight); this.clock = clock; // Set the initial network type and bitrate estimate @@ -427,8 +427,7 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList return; } lastReportedBitrateEstimate = bitrateEstimate; - eventDispatcher.dispatch( - listener -> listener.onBandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate)); + eventDispatcher.bandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate); } private long getInitialBitrateEstimateForNetworkType(@C.NetworkType int networkType) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java deleted file mode 100644 index 07f278c808..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.util; - -import android.os.Handler; -import java.util.concurrent.CopyOnWriteArrayList; - -/** - * Event dispatcher which allows listener registration. - * - * @param The type of listener. - */ -public final class EventDispatcher { - - /** Functional interface to send an event. */ - public interface Event { - - /** - * Sends the event to a listener. - * - * @param listener The listener to send the event to. - */ - void sendTo(T listener); - } - - /** The list of listeners and handlers. */ - private final CopyOnWriteArrayList> listeners; - - /** Creates an event dispatcher. */ - public EventDispatcher() { - listeners = new CopyOnWriteArrayList<>(); - } - - /** Adds a listener to the event dispatcher. */ - public void addListener(Handler handler, T eventListener) { - Assertions.checkArgument(handler != null && eventListener != null); - removeListener(eventListener); - listeners.add(new HandlerAndListener<>(handler, eventListener)); - } - - /** Removes a listener from the event dispatcher. */ - public void removeListener(T eventListener) { - for (HandlerAndListener handlerAndListener : listeners) { - if (handlerAndListener.listener == eventListener) { - handlerAndListener.release(); - listeners.remove(handlerAndListener); - } - } - } - - /** - * Dispatches an event to all registered listeners. - * - * @param event The {@link Event}. - */ - public void dispatch(Event event) { - for (HandlerAndListener handlerAndListener : listeners) { - handlerAndListener.dispatch(event); - } - } - - private static final class HandlerAndListener { - - private final Handler handler; - private final T listener; - - private boolean released; - - public HandlerAndListener(Handler handler, T eventListener) { - this.handler = handler; - this.listener = eventListener; - } - - public void release() { - released = true; - } - - public void dispatch(Event event) { - handler.post( - () -> { - if (!released) { - event.sendTo(listener); - } - }); - } - } -} From e486dc602c5ec328e7a40204b1b3d6f9419564f0 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Tue, 14 Jul 2020 18:12:19 +0100 Subject: [PATCH 0680/1052] Release Extractor resources in DASH PiperOrigin-RevId: 321181453 --- .../source/chunk/BundledChunkExtractor.java | 5 ++ .../source/chunk/ChunkExtractor.java | 3 ++ .../source/chunk/ChunkSampleStream.java | 1 + .../exoplayer2/source/chunk/ChunkSource.java | 3 ++ .../exoplayer2/source/dash/DashUtil.java | 51 ++++++++++--------- .../source/dash/DefaultDashChunkSource.java | 10 ++++ .../smoothstreaming/DefaultSsChunkSource.java | 7 +++ .../exoplayer2/testutil/FakeChunkSource.java | 4 ++ 8 files changed, 61 insertions(+), 23 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java index f5b05db047..f02329d5d5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java @@ -104,6 +104,11 @@ public final class BundledChunkExtractor implements ExtractorOutput, ChunkExtrac } } + @Override + public void release() { + extractor.release(); + } + @Override public boolean read(ExtractorInput input) throws IOException { int result = extractor.read(input, POSITION_HOLDER); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java index 215e965ca0..6bfe9590db 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractor.java @@ -74,6 +74,9 @@ public interface ChunkExtractor { */ void init(@Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs); + /** Releases any held resources. */ + void release(); + /** * Reads from the given {@link ExtractorInput}. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index 9238ef1c7c..bff7c7870b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -355,6 +355,7 @@ public class ChunkSampleStream implements SampleStream, S for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { embeddedSampleQueue.release(); } + chunkSource.release(); if (releaseCallback != null) { releaseCallback.onSampleStreamReleased(this); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java index e05dab69d3..32ac6fee7a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java @@ -104,4 +104,7 @@ public interface ChunkSource { * chunk. */ boolean onChunkLoadError(Chunk chunk, boolean cancelable, Exception e, long exclusionDurationMs); + + /** Releases any held resources. */ + void release(); } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java index 10c69ce65c..b0c892de03 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java @@ -114,11 +114,16 @@ public final class DashUtil { @Nullable public static Format loadSampleFormat( DataSource dataSource, int trackType, Representation representation) throws IOException { - ChunkExtractor chunkExtractor = - loadInitializationData(dataSource, trackType, representation, false); - return chunkExtractor == null - ? null - : Assertions.checkStateNotNull(chunkExtractor.getSampleFormats())[0]; + if (representation.getInitializationUri() == null) { + return null; + } + ChunkExtractor chunkExtractor = newChunkExtractor(trackType, representation.format); + try { + loadInitializationData(chunkExtractor, dataSource, representation, /* loadIndex= */ false); + } finally { + chunkExtractor.release(); + } + return Assertions.checkStateNotNull(chunkExtractor.getSampleFormats())[0]; } /** @@ -136,39 +141,40 @@ public final class DashUtil { @Nullable public static ChunkIndex loadChunkIndex( DataSource dataSource, int trackType, Representation representation) throws IOException { - @Nullable - ChunkExtractor chunkExtractor = - loadInitializationData(dataSource, trackType, representation, true); - return chunkExtractor == null ? null : chunkExtractor.getChunkIndex(); + if (representation.getInitializationUri() == null) { + return null; + } + ChunkExtractor chunkExtractor = newChunkExtractor(trackType, representation.format); + try { + loadInitializationData(chunkExtractor, dataSource, representation, /* loadIndex= */ true); + } finally { + chunkExtractor.release(); + } + return chunkExtractor.getChunkIndex(); } /** * Loads initialization data for the {@code representation} and optionally index data then returns * a {@link BundledChunkExtractor} which contains the output. * + * @param chunkExtractor The {@link ChunkExtractor} to use. * @param dataSource The source from which the data should be loaded. - * @param trackType The type of the representation. Typically one of the {@link - * com.google.android.exoplayer2.C} {@code TRACK_TYPE_*} constants. * @param representation The representation which initialization chunk belongs to. * @param loadIndex Whether to load index data too. - * @return A {@link BundledChunkExtractor} for the {@code representation}, or null if no - * initialization or (if requested) index data exists. * @throws IOException Thrown when there is an error while loading. */ - @Nullable - private static ChunkExtractor loadInitializationData( - DataSource dataSource, int trackType, Representation representation, boolean loadIndex) + private static void loadInitializationData( + ChunkExtractor chunkExtractor, + DataSource dataSource, + Representation representation, + boolean loadIndex) throws IOException { - RangedUri initializationUri = representation.getInitializationUri(); - if (initializationUri == null) { - return null; - } - ChunkExtractor chunkExtractor = newChunkExtractor(trackType, representation.format); + RangedUri initializationUri = Assertions.checkNotNull(representation.getInitializationUri()); RangedUri requestUri; if (loadIndex) { RangedUri indexUri = representation.getIndexUri(); if (indexUri == null) { - return null; + return; } // It's common for initialization and index data to be stored adjacently. Attempt to merge // the two requests together to request both at once. @@ -181,7 +187,6 @@ public final class DashUtil { requestUri = initializationUri; } loadInitializationData(dataSource, representation, chunkExtractor, requestUri); - return chunkExtractor; } private static void loadInitializationData( diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index ff62aabef2..366a507b4a 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -442,6 +442,16 @@ public class DefaultDashChunkSource implements DashChunkSource { && trackSelection.blacklist(trackSelection.indexOf(chunk.trackFormat), exclusionDurationMs); } + @Override + public void release() { + for (RepresentationHolder representationHolder : representationHolders) { + @Nullable ChunkExtractor chunkExtractor = representationHolder.chunkExtractor; + if (chunkExtractor != null) { + chunkExtractor.release(); + } + } + } + // Internal methods. private long getSegmentNum( diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java index c7df39033b..3760a5337d 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java @@ -271,6 +271,13 @@ public class DefaultSsChunkSource implements SsChunkSource { && trackSelection.blacklist(trackSelection.indexOf(chunk.trackFormat), exclusionDurationMs); } + @Override + public void release() { + for (ChunkExtractor chunkExtractor : chunkExtractors) { + chunkExtractor.release(); + } + } + // Private methods. private static MediaChunk newMediaChunk( diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java index 844823205a..c703cf0bc3 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java @@ -150,4 +150,8 @@ public final class FakeChunkSource implements ChunkSource { return false; } + @Override + public void release() { + // Do nothing. + } } From e7b76354b9156013b8a7723f07dce271e53e1089 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 14 Jul 2020 21:04:30 +0100 Subject: [PATCH 0681/1052] Add Player.EventListener.onMediaItemTransition PiperOrigin-RevId: 321218451 --- RELEASENOTES.md | 12 +- .../android/exoplayer2/ExoPlayerImpl.java | 82 ++++- .../com/google/android/exoplayer2/Player.java | 35 ++ .../analytics/AnalyticsCollector.java | 10 + .../analytics/AnalyticsListener.java | 13 + .../android/exoplayer2/util/EventLogger.java | 32 ++ .../android/exoplayer2/ExoPlayerTest.java | 323 ++++++++++++++++++ .../testutil/ExoPlayerTestRunner.java | 36 +- .../exoplayer2/testutil/FakeTimeline.java | 45 ++- 9 files changed, 578 insertions(+), 10 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 348357937f..9d34211b1d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -31,9 +31,10 @@ * Add `play` and `pause` methods to `Player`. * Add `Player.getCurrentLiveOffset` to conveniently return the live offset. - * Add `Player.onPlayWhenReadyChanged` with reasons. - * Add `Player.onPlaybackStateChanged` and deprecate - `Player.onPlayerStateChanged`. + * Add `Player.EventListener.onPlayWhenReadyChanged` with reasons. + * Add `Player.EventListener.onPlaybackStateChanged` and deprecate + `Player.EventListener.onPlayerStateChanged`. + * Add `Player.EventListener.onMediaItemTransition` with reasons. * Add `Player.setAudioSessionId` to set the session ID attached to the `AudioTrack`. * Deprecate and rename `getPlaybackError` to `getPlayerError` for @@ -242,9 +243,8 @@ * Cast extension: Implement playlist API and deprecate the old queue manipulation API. * IMA extension: - * Upgrade to IMA SDK 3.19.4, bringing in a fix for setting the - media load timeout - ([#7170](https://github.com/google/ExoPlayer/issues/7170)). + * Upgrade to IMA SDK 3.19.4, bringing in a fix for setting the media load + timeout ([#7170](https://github.com/google/ExoPlayer/issues/7170)). * Migrate to new 'friendly obstruction' IMA SDK APIs, and allow apps to register a purpose and detail reason for overlay views via `AdsLoader.AdViewProvider`. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 8482a584d2..4520054b90 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -990,6 +990,22 @@ import java.util.concurrent.TimeoutException; // Assign playback info immediately such that all getters return the right values. PlaybackInfo previousPlaybackInfo = this.playbackInfo; this.playbackInfo = playbackInfo; + + Pair mediaItemTransitionInfo = + evaluateMediaItemTransitionReason( + playbackInfo, + previousPlaybackInfo, + positionDiscontinuity, + positionDiscontinuityReason, + !previousPlaybackInfo.timeline.equals(playbackInfo.timeline)); + boolean mediaItemTransitioned = mediaItemTransitionInfo.first; + int mediaItemTransitionReason = mediaItemTransitionInfo.second; + @Nullable MediaItem newMediaItem = null; + if (mediaItemTransitioned && !playbackInfo.timeline.isEmpty()) { + int windowIndex = + playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; + newMediaItem = playbackInfo.timeline.getWindow(windowIndex, window).mediaItem; + } notifyListeners( new PlaybackInfoUpdate( playbackInfo, @@ -999,10 +1015,58 @@ import java.util.concurrent.TimeoutException; positionDiscontinuity, positionDiscontinuityReason, timelineChangeReason, + mediaItemTransitioned, + mediaItemTransitionReason, + newMediaItem, playWhenReadyChangeReason, seekProcessed)); } + private Pair evaluateMediaItemTransitionReason( + PlaybackInfo playbackInfo, + PlaybackInfo oldPlaybackInfo, + boolean positionDiscontinuity, + int positionDiscontinuityReason, + boolean timelineChanged) { + + Timeline oldTimeline = oldPlaybackInfo.timeline; + Timeline newTimeline = playbackInfo.timeline; + if (newTimeline.isEmpty() && oldTimeline.isEmpty()) { + return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); + } else if (newTimeline.isEmpty() != oldTimeline.isEmpty()) { + return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + int oldWindowIndex = + oldTimeline.getPeriodByUid(oldPlaybackInfo.periodId.periodUid, period).windowIndex; + Object oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid; + int newWindowIndex = + newTimeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; + Object newWindowUid = newTimeline.getWindow(newWindowIndex, window).uid; + int firstPeriodIndexInNewWindow = window.firstPeriodIndex; + if (!oldWindowUid.equals(newWindowUid)) { + @Player.MediaItemTransitionReason int transitionReason; + if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_PERIOD_TRANSITION) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_AUTO; + } else if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_SEEK; + } else if (timelineChanged) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; + } else { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_SKIP; + } + return new Pair<>(/* isTransitioning */ true, transitionReason); + } else if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_PERIOD_TRANSITION + && newTimeline.getIndexOfPeriod(playbackInfo.periodId.periodUid) + == firstPeriodIndexInNewWindow) { + return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_REPEAT); + } + return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); + } + private void setMediaSourcesInternal( List mediaSources, int startWindowIndex, @@ -1388,16 +1452,19 @@ import java.util.concurrent.TimeoutException; private final boolean positionDiscontinuity; @DiscontinuityReason private final int positionDiscontinuityReason; @TimelineChangeReason private final int timelineChangeReason; + private final boolean mediaItemTransitioned; + private final int mediaItemTransitionReason; + @Nullable private final MediaItem mediaItem; @PlayWhenReadyChangeReason private final int playWhenReadyChangeReason; private final boolean seekProcessed; private final boolean playbackStateChanged; private final boolean playbackErrorChanged; - private final boolean timelineChanged; private final boolean isLoadingChanged; + private final boolean timelineChanged; private final boolean trackSelectorResultChanged; - private final boolean isPlayingChanged; private final boolean playWhenReadyChanged; private final boolean playbackSuppressionReasonChanged; + private final boolean isPlayingChanged; public PlaybackInfoUpdate( PlaybackInfo playbackInfo, @@ -1407,6 +1474,9 @@ import java.util.concurrent.TimeoutException; boolean positionDiscontinuity, @DiscontinuityReason int positionDiscontinuityReason, @TimelineChangeReason int timelineChangeReason, + boolean mediaItemTransitioned, + @MediaItemTransitionReason int mediaItemTransitionReason, + @Nullable MediaItem mediaItem, @PlayWhenReadyChangeReason int playWhenReadyChangeReason, boolean seekProcessed) { this.playbackInfo = playbackInfo; @@ -1415,6 +1485,9 @@ import java.util.concurrent.TimeoutException; this.positionDiscontinuity = positionDiscontinuity; this.positionDiscontinuityReason = positionDiscontinuityReason; this.timelineChangeReason = timelineChangeReason; + this.mediaItemTransitioned = mediaItemTransitioned; + this.mediaItemTransitionReason = mediaItemTransitionReason; + this.mediaItem = mediaItem; this.playWhenReadyChangeReason = playWhenReadyChangeReason; this.seekProcessed = seekProcessed; playbackStateChanged = previousPlaybackInfo.playbackState != playbackInfo.playbackState; @@ -1444,6 +1517,11 @@ import java.util.concurrent.TimeoutException; listenerSnapshot, listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason)); } + if (mediaItemTransitioned) { + invokeAll( + listenerSnapshot, + listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); + } if (playbackErrorChanged) { invokeAll(listenerSnapshot, listener -> listener.onPlayerError(playbackInfo.playbackError)); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 47b93e0120..49f50466d2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -470,6 +470,15 @@ public interface Player { default void onTimelineChanged( Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {} + /** + * Called when playback transitions to a different media item. + * + * @param mediaItem The {@link MediaItem}. May be null if the timeline becomes empty. + * @param reason The reason for the transition. + */ + default void onMediaItemTransition( + @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) {} + /** * Called when the available or selected tracks change. * @@ -766,6 +775,32 @@ public interface Player { /** Timeline changed as a result of a dynamic update introduced by the played media. */ int TIMELINE_CHANGE_REASON_SOURCE_UPDATE = 1; + /** Reasons for media item transitions. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + MEDIA_ITEM_TRANSITION_REASON_REPEAT, + MEDIA_ITEM_TRANSITION_REASON_AUTO, + MEDIA_ITEM_TRANSITION_REASON_SEEK, + MEDIA_ITEM_TRANSITION_REASON_SKIP, + MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED + }) + @interface MediaItemTransitionReason {} + /** The media item has been repeated. */ + int MEDIA_ITEM_TRANSITION_REASON_REPEAT = 0; + /** Playback has automatically transitioned to the next media item. */ + int MEDIA_ITEM_TRANSITION_REASON_AUTO = 1; + /** A seek to another media item has occurred. */ + int MEDIA_ITEM_TRANSITION_REASON_SEEK = 2; + /** Playback skipped to a new media item (for example after failure). */ + int MEDIA_ITEM_TRANSITION_REASON_SKIP = 3; + /** + * The current media item has changed because of a modification of the timeline. This can either + * be if the period previously being played has been removed, or when the timeline becomes + * non-empty after being empty. + */ + int MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED = 4; + /** The default playback speed. */ float DEFAULT_PLAYBACK_SPEED = 1.0f; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 96359196e0..7fd8273c04 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -22,6 +22,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -455,6 +456,15 @@ public class AnalyticsCollector } } + @Override + public final void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onMediaItemTransition(eventTime, mediaItem, reason); + } + } + @Override public final void onTracksChanged( TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java index 7fef48154a..1125e60690 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -20,6 +20,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; @@ -207,6 +208,18 @@ public interface AnalyticsListener { */ default void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int reason) {} + /** + * Called when playback transitions to a different media item. + * + * @param eventTime The event time. + * @param mediaItem The media item. + * @param reason The reason for the media item transition. + */ + default void onMediaItemTransition( + EventTime eventTime, + @Nullable MediaItem mediaItem, + @Player.MediaItemTransitionReason int reason) {} + /** * Called when a position discontinuity occurred. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index 04e10472c9..27b8dd2814 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -22,6 +22,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -196,6 +197,19 @@ public class EventLogger implements AnalyticsListener { logd("]"); } + @Override + public void onMediaItemTransition( + EventTime eventTime, @Nullable MediaItem mediaItem, int reason) { + logd( + "mediaItem [" + + getEventTimeString(eventTime) + + ", " + + (mediaItem == null ? "null" : "mediaId=" + mediaItem.mediaId) + + ", reason=" + + getMediaItemTransitionReasonString(reason) + + "]"); + } + @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException e) { loge(eventTime, "playerFailed", e); @@ -648,6 +662,24 @@ public class EventLogger implements AnalyticsListener { } } + private static String getMediaItemTransitionReasonString( + @Player.MediaItemTransitionReason int reason) { + switch (reason) { + case Player.MEDIA_ITEM_TRANSITION_REASON_AUTO: + return "AUTO"; + case Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED: + return "PLAYLIST_CHANGED"; + case Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT: + return "REPEAT"; + case Player.MEDIA_ITEM_TRANSITION_REASON_SEEK: + return "SEEK"; + case Player.MEDIA_ITEM_TRANSITION_REASON_SKIP: + return "SKIP"; + default: + return "?"; + } + } + private static String getPlaybackSuppressionReasonString( @PlaybackSuppressionReason int playbackSuppressionReason) { switch (playbackSuppressionReason) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 2fe26bddbb..ee9d7668bc 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -7886,6 +7886,329 @@ public final class ExoPlayerTest { assertThat(initialMediaItems).containsExactlyElementsIn(currentMediaItems); } + @Test + public void setMediaSources_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource = factory.setTag("1").createMediaSource(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void setMediaSources_replaceWithSameMediaItem_notifiesMediaItemTransition() + throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource = factory.setTag("1").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .setMediaSources(mediaSource) + .waitForPlaybackState(Player.STATE_READY) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource.getMediaItem(), mediaSource.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void automaticWindowTransition_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void clearMediaItem_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 2000) + .clearMediaItems() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem(), null); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void seekTo_otherWindow_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* windowIndex= */ 1, /* positionMs= */ 2000) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + } + + @Test + public void seekTo_sameWindow_doesNotNotifyMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .seek(/* windowIndex= */ 0, /* positionMs= */ 20_000) + .stop() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void repeat_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setRepeatMode(Player.REPEAT_MODE_ONE); + } + }) + .play() + .waitForPositionDiscontinuity() + .waitForPositionDiscontinuity() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setRepeatMode(Player.REPEAT_MODE_OFF); + } + }) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), + mediaSource1.getMediaItem(), + mediaSource1.getMediaItem(), + mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT, + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void stop_withReset_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ true) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem(), null); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void stop_withoutReset_doesNotNotifyMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ false) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void timelineRefresh_withModifiedMediaItem_doesNotNotifyMediaItemTransition() + throws Exception { + MediaItem initialMediaItem = FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(0).build(); + TimelineWindowDefinition initialWindow = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ 0, + AdPlaybackState.NONE, + initialMediaItem); + TimelineWindowDefinition secondWindow = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ 0, + AdPlaybackState.NONE, + initialMediaItem.buildUpon().setTag(1).build()); + FakeTimeline timeline = new FakeTimeline(initialWindow); + FakeTimeline newTimeline = new FakeTimeline(secondWindow); + FakeMediaSource mediaSource = new FakeMediaSource(timeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .waitForPlayWhenReady(false) + .executeRunnable( + () -> { + mediaSource.setNewSourceInfo(newTimeline); + }) + .play() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertTimelinesSame(placeholderTimeline, timeline, newTimeline); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertMediaItemsTransitionedSame(initialMediaItem); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java index 2869c5b0f2..a28f6ee81b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RenderersFactory; @@ -356,6 +357,8 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private final CountDownLatch actionScheduleFinishedCountDownLatch; private final ArrayList timelines; private final ArrayList timelineChangeReasons; + private final ArrayList mediaItems; + private final ArrayList mediaItemTransitionReasons; private final ArrayList periodIndices; private final ArrayList discontinuityReasons; private final ArrayList playbackStates; @@ -387,6 +390,8 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc this.analyticsListener = analyticsListener; timelines = new ArrayList<>(); timelineChangeReasons = new ArrayList<>(); + mediaItems = new ArrayList<>(); + mediaItemTransitionReasons = new ArrayList<>(); periodIndices = new ArrayList<>(); discontinuityReasons = new ArrayList<>(); playbackStates = new ArrayList<>(); @@ -525,12 +530,34 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc assertThat(timelineChangeReasons).containsExactlyElementsIn(Arrays.asList(reasons)).inOrder(); } + /** + * Asserts that the media items reported by {@link + * Player.EventListener#onMediaItemTransition(MediaItem, int)} are the same as the provided media + * items. + * + * @param mediaItems A list of expected {@link MediaItem media items}. + */ + public void assertMediaItemsTransitionedSame(MediaItem... mediaItems) { + assertThat(this.mediaItems).containsExactlyElementsIn(mediaItems).inOrder(); + } + + /** + * Asserts that the media item transition reasons reported by {@link + * Player.EventListener#onMediaItemTransition(MediaItem, int)} are the same as the provided + * reasons. + * + * @param reasons A list of expected transition reasons. + */ + public void assertMediaItemsTransitionReasonsEqual(Integer... reasons) { + assertThat(this.mediaItemTransitionReasons).containsExactlyElementsIn(reasons).inOrder(); + } + /** * Asserts that the playback states reported by {@link * Player.EventListener#onPlaybackStateChanged(int)} are equal to the provided playback states. */ public void assertPlaybackStatesEqual(Integer... states) { - assertThat(playbackStates).containsExactlyElementsIn(Arrays.asList(states)).inOrder(); + assertThat(playbackStates).containsExactlyElementsIn(states).inOrder(); } /** @@ -617,6 +644,13 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc } } + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItems.add(mediaItem); + mediaItemTransitionReasons.add(reason); + } + @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { this.trackGroups = trackGroups; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 2d64d2f637..f1f0e9203b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -166,10 +166,53 @@ public final class FakeTimeline extends Timeline { long defaultPositionUs, long windowOffsetInFirstPeriodUs, AdPlaybackState adPlaybackState) { + this( + periodCount, + id, + isSeekable, + isDynamic, + isLive, + isPlaceholder, + durationUs, + defaultPositionUs, + windowOffsetInFirstPeriodUs, + adPlaybackState, + FAKE_MEDIA_ITEM.buildUpon().setTag(id).build()); + } + + /** + * Creates a window definition with ad groups and a custom media item. + * + * @param periodCount The number of periods in the window. Each period get an equal slice of the + * total window duration. + * @param id The UID of the window. + * @param isSeekable Whether the window is seekable. + * @param isDynamic Whether the window is dynamic. + * @param isLive Whether the window is live. + * @param isPlaceholder Whether the window is a placeholder. + * @param durationUs The duration of the window in microseconds. + * @param defaultPositionUs The default position of the window in microseconds. + * @param windowOffsetInFirstPeriodUs The offset of the window in the first period, in + * microseconds. + * @param adPlaybackState The ad playback state. + * @param mediaItem The media item to include in the timeline. + */ + public TimelineWindowDefinition( + int periodCount, + Object id, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + boolean isPlaceholder, + long durationUs, + long defaultPositionUs, + long windowOffsetInFirstPeriodUs, + AdPlaybackState adPlaybackState, + MediaItem mediaItem) { Assertions.checkArgument(durationUs != C.TIME_UNSET || periodCount == 1); this.periodCount = periodCount; this.id = id; - this.mediaItem = FAKE_MEDIA_ITEM.buildUpon().setTag(id).build(); + this.mediaItem = mediaItem; this.isSeekable = isSeekable; this.isDynamic = isDynamic; this.isLive = isLive; From aa67685071a1507dfd199be53939c8d853131e65 Mon Sep 17 00:00:00 2001 From: insun Date: Wed, 15 Jul 2020 02:58:09 +0100 Subject: [PATCH 0682/1052] Apply StyledPlayerView into default demo app PiperOrigin-RevId: 321280295 --- .../google/android/exoplayer2/demo/PlayerActivity.java | 8 ++++---- demos/main/src/main/res/layout/player_activity.xml | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 0ab527ad58..d15da29565 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -52,8 +52,8 @@ import com.google.android.exoplayer2.trackselection.RandomTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.DebugTextViewHelper; -import com.google.android.exoplayer2.ui.PlayerControlView; -import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.ui.StyledPlayerControlView; +import com.google.android.exoplayer2.ui.StyledPlayerView; import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.util.Assertions; @@ -69,7 +69,7 @@ import java.util.List; /** An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends AppCompatActivity - implements OnClickListener, PlaybackPreparer, PlayerControlView.VisibilityListener { + implements OnClickListener, PlaybackPreparer, StyledPlayerControlView.VisibilityListener { // Saved instance state keys. @@ -85,7 +85,7 @@ public class PlayerActivity extends AppCompatActivity DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); } - private PlayerView playerView; + private StyledPlayerView playerView; private LinearLayout debugRootView; private Button selectTracksButton; private TextView debugTextView; diff --git a/demos/main/src/main/res/layout/player_activity.xml b/demos/main/src/main/res/layout/player_activity.xml index ea3de257e2..5b897fa7ea 100644 --- a/demos/main/src/main/res/layout/player_activity.xml +++ b/demos/main/src/main/res/layout/player_activity.xml @@ -15,14 +15,17 @@ --> - + android:layout_height="match_parent" + app:show_shuffle_button="true" + app:show_subtitle_button="true"/> Date: Wed, 15 Jul 2020 11:49:04 +0100 Subject: [PATCH 0683/1052] Remove MediaSourceList.maybeThrowSourceInfoRefreshError The method has been called from two call sites in EPII triggered by EPII.updatePeriods(). The first call site was calling it when the MediaSourceList is empty or not yet prepared. This can be removed because if empty or not prepared no source ever could have thrown yet. The second call site was checking for potential source refresh exceptions when queue.getNextMediaPeriodInfo() returns null when trying to getting the next loading period. Looking into all reasons for why the method returns null, none of them is caused by an exception of a media source. The reasons are: - if we are at the last period of the timeline - if the defaultPosition of the next period in the timeline is null (if the window.durationUs == C.TIME_UNSET or defaultPositionProjectionUs is projected beyond the duration of the window) - if we are waiting for an ad uri to arrive (period.isAdAvailable(...) == false) - if we are waiting for the ad group count to be updated (adCountInCurrentAdGroup == C.LENGTH_UNSET) The above reasons are not caused by a source error and may be resolved when doSomeWork is called the next time. Hence it is save to remove the calls to maybeThrowSourceInfoRefreshError(). Beside this, an actual sourceInfoRefreshError will be reported by maskingMediaSource.maybeThrowPrepareError(), which is called each time doSomeWork() is called and the playing period is not yet prepared (EPII:L836). So the player is notified by source errors that way, which confirms removing the above calls is fine. PiperOrigin-RevId: 321331777 --- .../exoplayer2/ExoPlayerImplInternal.java | 23 ++++--------------- .../android/exoplayer2/MediaPeriodQueue.java | 2 ++ .../android/exoplayer2/MediaSourceList.java | 7 ------ 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index cb0aa22a38..622b351e54 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -1627,19 +1627,6 @@ import java.util.concurrent.atomic.AtomicBoolean; || !shouldPlayWhenReady()); } - private void maybeThrowSourceInfoRefreshError() throws IOException { - MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); - if (loadingPeriodHolder != null) { - // Defer throwing until we read all available media periods. - for (Renderer renderer : renderers) { - if (isRendererEnabled(renderer) && !renderer.hasReadStreamToEnd()) { - return; - } - } - } - mediaSourceList.maybeThrowSourceInfoRefreshError(); - } - private void handleMediaSourceListInfoRefreshed(Timeline timeline) throws ExoPlaybackException { PositionUpdateForPlaylistChange positionUpdate = resolvePositionForPlaylistChange( @@ -1733,8 +1720,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private void updatePeriods() throws ExoPlaybackException, IOException { if (playbackInfo.timeline.isEmpty() || !mediaSourceList.isPrepared()) { - // We're waiting to get information about periods. - mediaSourceList.maybeThrowSourceInfoRefreshError(); + // No periods available. return; } maybeUpdateLoadingPeriod(); @@ -1743,13 +1729,12 @@ import java.util.concurrent.atomic.AtomicBoolean; maybeUpdatePlayingPeriod(); } - private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException { + private void maybeUpdateLoadingPeriod() throws ExoPlaybackException { queue.reevaluateBuffer(rendererPositionUs); if (queue.shouldLoadNextMediaPeriod()) { + @Nullable MediaPeriodInfo info = queue.getNextMediaPeriodInfo(rendererPositionUs, playbackInfo); - if (info == null) { - maybeThrowSourceInfoRefreshError(); - } else { + if (info != null) { MediaPeriodHolder mediaPeriodHolder = queue.enqueueNextMediaPeriodHolder( rendererCapabilities, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index 65336971cd..b79eff8afb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -575,6 +575,7 @@ import com.google.common.collect.ImmutableList; /** * Returns the first {@link MediaPeriodInfo} to play, based on the specified playback position. */ + @Nullable private MediaPeriodInfo getFirstMediaPeriodInfo(PlaybackInfo playbackInfo) { return getMediaPeriodInfo( playbackInfo.timeline, @@ -729,6 +730,7 @@ import com.google.common.collect.ImmutableList; } } + @Nullable private MediaPeriodInfo getMediaPeriodInfo( Timeline timeline, MediaPeriodId id, long requestedContentPositionUs, long startPositionUs) { timeline.getPeriodByUid(id.periodUid, period); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java index 85a0b52789..21fbc04f1c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java @@ -342,13 +342,6 @@ import java.util.Set; isPrepared = false; } - /** Throws any pending error encountered while loading or refreshing. */ - public void maybeThrowSourceInfoRefreshError() throws IOException { - for (MediaSourceAndListener childSource : childSources.values()) { - childSource.mediaSource.maybeThrowSourceInfoRefreshError(); - } - } - /** Creates a timeline reflecting the current state of the playlist. */ public Timeline createTimeline() { if (mediaSourceHolders.isEmpty()) { From 890c4adbedae0c156249b4bf9cf6284e36b69a8f Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 15 Jul 2020 13:14:57 +0100 Subject: [PATCH 0684/1052] Clip float point PCM to its allowed range before resampling PiperOrigin-RevId: 321340777 --- .../exoplayer2/audio/ResamplingAudioProcessor.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java index 883f5bcb92..00d9bb4d1d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.audio; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; /** @@ -115,9 +116,13 @@ import java.nio.ByteBuffer; // 32 bit floating point -> 16 bit resampling. Floating point values are in the range // [-1.0, 1.0], so need to be scaled by Short.MAX_VALUE. for (int i = position; i < limit; i += 4) { - short value = (short) (inputBuffer.getFloat(i) * Short.MAX_VALUE); - buffer.put((byte) (value & 0xFF)); - buffer.put((byte) ((value >> 8) & 0xFF)); + // Clamp to avoid integer overflow if the floating point values exceed their allowed range + // [Internal ref: b/161204847]. + float floatValue = + Util.constrainValue(inputBuffer.getFloat(i), /* min= */ -1, /* max= */ 1); + short shortValue = (short) (floatValue * Short.MAX_VALUE); + buffer.put((byte) (shortValue & 0xFF)); + buffer.put((byte) ((shortValue >> 8) & 0xFF)); } break; case C.ENCODING_PCM_16BIT: From 422f451cf9e338052e1a14ab2d26059e12823296 Mon Sep 17 00:00:00 2001 From: krocard Date: Wed, 15 Jul 2020 13:57:25 +0100 Subject: [PATCH 0685/1052] Name [-1,1] the "nominal" range of float samples Float values are allowed to be > 0dbfs, it is just not nominal as it will might distort the signal when played without attenuation. This is also consistent with [AudioTrack.write(FloatBuffer)](https://developer.android.com/reference/android/media/AudioTrack#write(float[],%20int,%20int,%20int)) that explicitly allows it up to 3dbfs. PiperOrigin-RevId: 321345077 --- .../android/exoplayer2/audio/ResamplingAudioProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java index 00d9bb4d1d..a4d2a1b67a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java @@ -116,7 +116,7 @@ import java.nio.ByteBuffer; // 32 bit floating point -> 16 bit resampling. Floating point values are in the range // [-1.0, 1.0], so need to be scaled by Short.MAX_VALUE. for (int i = position; i < limit; i += 4) { - // Clamp to avoid integer overflow if the floating point values exceed their allowed range + // Clamp to avoid integer overflow if the floating point values exceed their nominal range // [Internal ref: b/161204847]. float floatValue = Util.constrainValue(inputBuffer.getFloat(i), /* min= */ -1, /* max= */ 1); From f55526f7bc1b203ce6a5604bff6688e43a2a1d2f Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 15 Jul 2020 16:35:53 +0100 Subject: [PATCH 0686/1052] Replace overrides of deprecated AnalyticsListener#onPlayerStateChanged Also remove some duplicate logging from ExoHostedTest - EventLogger logs the same info already. PiperOrigin-RevId: 321366052 --- .../google/android/exoplayer2/ExoPlayerTest.java | 13 +++++++++++-- .../exoplayer2/testutil/ExoHostedTest.java | 16 +++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index ee9d7668bc..994ea7443f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -1889,8 +1889,17 @@ public final class ExoPlayerTest { AnalyticsListener listener = new AnalyticsListener() { @Override - public void onPlayerStateChanged( - EventTime eventTime, boolean playWhenReady, int playbackState) { + public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) { + if (eventTime.mediaPeriodId != null) { + reportedWindowSequenceNumbers.add(eventTime.mediaPeriodId.windowSequenceNumber); + } + } + + @Override + public void onPlayWhenReadyChanged( + EventTime eventTime, + boolean playWhenReady, + @Player.PlayWhenReadyChangeReason int reason) { if (eventTime.mediaPeriodId != null) { reportedWindowSequenceNumbers.add(eventTime.mediaPeriodId.windowSequenceNumber); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index d8dabf05b0..eba309abdf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -39,7 +39,6 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.EventLogger; import com.google.android.exoplayer2.util.HandlerWrapper; -import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -74,7 +73,6 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { private @MonotonicNonNull ExoPlaybackException playerError; private boolean playerWasPrepared; - private boolean playing; private long totalPlayingTimeMs; private long lastPlayingStartTimeMs; private long sourceDurationMs; @@ -186,21 +184,21 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { // AnalyticsListener @Override - public final void onPlayerStateChanged( - EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { - Log.d(tag, "state [" + playWhenReady + ", " + playbackState + "]"); + public final void onPlaybackStateChanged(EventTime eventTime, @Player.State int playbackState) { playerWasPrepared |= playbackState != Player.STATE_IDLE; if (playbackState == Player.STATE_ENDED || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { stopTest(); } - boolean playing = playWhenReady && playbackState == Player.STATE_READY; - if (!this.playing && playing) { + } + + @Override + public void onIsPlayingChanged(EventTime eventTime, boolean playing) { + if (playing) { lastPlayingStartTimeMs = SystemClock.elapsedRealtime(); - } else if (this.playing && !playing) { + } else { totalPlayingTimeMs += SystemClock.elapsedRealtime() - lastPlayingStartTimeMs; } - this.playing = playing; } @Override From 6927239a737deeaa450bb50ce3a1c8a39c271461 Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 15 Jul 2020 16:42:21 +0100 Subject: [PATCH 0687/1052] Remove some occurrences of gendered pronouns ISSUE: #7565 PiperOrigin-RevId: 321367089 --- .../android/exoplayer2/text/ttml/TtmlDecoderTest.java | 6 +++--- testdata/src/test/assets/ttml/multiple_regions.xml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java index 761814d526..831ee6def2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java @@ -304,16 +304,16 @@ public final class TtmlDecoderTest { // assertEquals(1f, cue.size); cue = getOnlyCueAtTimeUs(subtitle, 21_000_000); - assertThat(cue.text.toString()).isEqualTo("She first said this"); + assertThat(cue.text.toString()).isEqualTo("They first said this"); assertThat(cue.position).isEqualTo(45f / 100f); assertThat(cue.line).isEqualTo(45f / 100f); assertThat(cue.size).isEqualTo(35f / 100f); cue = getOnlyCueAtTimeUs(subtitle, 25_000_000); - assertThat(cue.text.toString()).isEqualTo("She first said this\nThen this"); + assertThat(cue.text.toString()).isEqualTo("They first said this\nThen this"); cue = getOnlyCueAtTimeUs(subtitle, 29_000_000); - assertThat(cue.text.toString()).isEqualTo("She first said this\nThen this\nFinally this"); + assertThat(cue.text.toString()).isEqualTo("They first said this\nThen this\nFinally this"); assertThat(cue.position).isEqualTo(45f / 100f); assertThat(cue.line).isEqualTo(45f / 100f); } diff --git a/testdata/src/test/assets/ttml/multiple_regions.xml b/testdata/src/test/assets/ttml/multiple_regions.xml index 3bde2c99b5..9404cd47d1 100644 --- a/testdata/src/test/assets/ttml/multiple_regions.xml +++ b/testdata/src/test/assets/ttml/multiple_regions.xml @@ -21,7 +21,7 @@

        amet

        -

        She first said this

        +

        They first said this

        Then this

        Finally this

        From e682f53b3c8e2effbf57cb371d92fa98437fd75a Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 15 Jul 2020 16:59:31 +0100 Subject: [PATCH 0688/1052] Migrate overrides of deprecated onPlaybackParametersChanged This method has been replaced by onPlaybackSpeedChanged PiperOrigin-RevId: 321369921 --- .../android/exoplayer2/ext/media2/PlayerWrapper.java | 10 ++++------ .../google/android/exoplayer2/util/EventLogger.java | 10 ---------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java index b04ff5f3de..82f50aceca 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java @@ -25,7 +25,6 @@ import androidx.media2.common.SessionPlayer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ControlDispatcher; import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; @@ -421,9 +420,8 @@ import java.util.List; listener.onShuffleModeChanged(Utils.getShuffleMode(shuffleModeEnabled)); } - @SuppressWarnings("deprecation") - private void handlePlaybackParametersChanged(PlaybackParameters playbackParameters) { - listener.onPlaybackSpeedChanged(playbackParameters.speed); + private void handlePlaybackSpeedChanged(float playbackSpeed) { + listener.onPlaybackSpeedChanged(playbackSpeed); } private void handleTimelineChanged() { @@ -508,8 +506,8 @@ import java.util.List; } @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - handlePlaybackParametersChanged(playbackParameters); + public void onPlaybackSpeedChanged(float playbackSpeed) { + handlePlaybackSpeedChanged(playbackSpeed); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index 27b8dd2814..27b95515c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -23,7 +23,6 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; import com.google.android.exoplayer2.RendererCapabilities; @@ -145,15 +144,6 @@ public class EventLogger implements AnalyticsListener { logd(eventTime, "seekStarted"); } - @Override - public void onPlaybackParametersChanged( - EventTime eventTime, PlaybackParameters playbackParameters) { - logd( - eventTime, - "playbackParameters", - Util.formatInvariant("speed=%.2f", playbackParameters.speed)); - } - @Override public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { logd(eventTime, "playbackSpeed", Float.toString(playbackSpeed)); From 8cc3cc4e148011cd3b7d05590996223023e926c9 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 15 Jul 2020 18:00:57 +0100 Subject: [PATCH 0689/1052] Assume renderer errors are thrown for reading period. This fixes a bug that renderer errors are currently falsely associated with the playing period. PiperOrigin-RevId: 321381705 --- RELEASENOTES.md | 2 + .../exoplayer2/ExoPlaybackException.java | 55 ++- .../exoplayer2/ExoPlayerImplInternal.java | 13 + .../analytics/AnalyticsCollector.java | 5 +- .../android/exoplayer2/ExoPlayerTest.java | 383 ++++++++---------- .../analytics/AnalyticsCollectorTest.java | 114 ++++++ 6 files changed, 353 insertions(+), 219 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9d34211b1d..208997a1ae 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -99,6 +99,8 @@ parameter ([#7582](https://github.com/google/ExoPlayer/issues/7582)). * Distinguish between `offsetUs` and `startPositionUs` when passing new `SampleStreams` to `Renderers`. + * Fix wrong `MediaPeriodId` for some renderer errors reported by + `AnalyticsListener.onPlayerError`. * Video: Pass frame rate hint to `Surface.setFrameRate` on Android R devices. * Track selection: * Add `Player.getTrackSelector`. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java index cd9662a251..31159c0e47 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2; import android.os.SystemClock; import android.text.TextUtils; +import androidx.annotation.CheckResult; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.RendererCapabilities.FormatSupport; @@ -93,6 +94,12 @@ public final class ExoPlaybackException extends Exception { /** The value of {@link SystemClock#elapsedRealtime()} when this exception was created. */ public final long timestampMs; + /** + * The {@link MediaSource.MediaPeriodId} of the media associated with this error, or null if + * undetermined. + */ + @Nullable public final MediaSource.MediaPeriodId mediaPeriodId; + @Nullable private final Throwable cause; /** @@ -192,7 +199,7 @@ public final class ExoPlaybackException extends Exception { int rendererIndex, @Nullable Format rendererFormat, @FormatSupport int rendererFormatSupport) { - super( + this( deriveMessage( type, customMessage, @@ -200,14 +207,35 @@ public final class ExoPlaybackException extends Exception { rendererIndex, rendererFormat, rendererFormatSupport), - cause); + cause, + type, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + /* mediaPeriodId= */ null, + /* timestampMs= */ SystemClock.elapsedRealtime()); + } + + private ExoPlaybackException( + @Nullable String message, + @Nullable Throwable cause, + @Type int type, + @Nullable String rendererName, + int rendererIndex, + @Nullable Format rendererFormat, + @FormatSupport int rendererFormatSupport, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + long timestampMs) { + super(message, cause); this.type = type; this.cause = cause; this.rendererName = rendererName; this.rendererIndex = rendererIndex; this.rendererFormat = rendererFormat; this.rendererFormatSupport = rendererFormatSupport; - timestampMs = SystemClock.elapsedRealtime(); + this.mediaPeriodId = mediaPeriodId; + this.timestampMs = timestampMs; } /** @@ -250,6 +278,27 @@ public final class ExoPlaybackException extends Exception { return (OutOfMemoryError) Assertions.checkNotNull(cause); } + /** + * Returns a copy of this exception with the provided {@link MediaSource.MediaPeriodId}. + * + * @param mediaPeriodId The {@link MediaSource.MediaPeriodId}. + * @return The copied exception. + */ + @CheckResult + /* package= */ ExoPlaybackException copyWithMediaPeriodId( + @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + return new ExoPlaybackException( + getMessage(), + cause, + type, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + mediaPeriodId, + timestampMs); + } + @Nullable private static String deriveMessage( @Type int type, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 622b351e54..07f5234e24 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -528,6 +528,14 @@ import java.util.concurrent.atomic.AtomicBoolean; } maybeNotifyPlaybackInfoChanged(); } catch (ExoPlaybackException e) { + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + @Nullable MediaPeriodHolder readingPeriod = queue.getReadingPeriod(); + if (readingPeriod != null) { + // We can assume that all renderer errors happen in the context of the reading period. See + // [internal: b/150584930#comment4] for exceptions that aren't covered by this assumption. + e = e.copyWithMediaPeriodId(readingPeriod.info.id); + } + } Log.e(TAG, "Playback error", e); stopInternal( /* forceResetRenderers= */ true, @@ -537,6 +545,11 @@ import java.util.concurrent.atomic.AtomicBoolean; maybeNotifyPlaybackInfoChanged(); } catch (IOException e) { ExoPlaybackException error = ExoPlaybackException.createForSource(e); + @Nullable MediaPeriodHolder playingPeriod = queue.getPlayingPeriod(); + if (playingPeriod != null) { + // We ensure that all IOException throwing methods are only executed for the playing period. + error = error.copyWithMediaPeriodId(playingPeriod.info.id); + } Log.e(TAG, "Playback error", error); stopInternal( /* forceResetRenderers= */ false, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 7fd8273c04..d7bda740b0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -543,7 +543,10 @@ public class AnalyticsCollector @Override public final void onPlayerError(ExoPlaybackException error) { - EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); + EventTime eventTime = + error.mediaPeriodId != null + ? generateEventTime(error.mediaPeriodId) + : generateCurrentPlayerMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { listener.onPlayerError(eventTime, error); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 994ea7443f..4733637c82 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -3066,87 +3066,6 @@ public final class ExoPlayerTest { .isEqualTo(ExoPlayerTestRunner.AUDIO_FORMAT); } - @Test - public void secondMediaSourceInPlaylistOnlyThrowsWhenPreviousPeriodIsFullyRead() - throws Exception { - Timeline fakeTimeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs= */ 10 * C.MICROS_PER_SECOND)); - MediaSource workingMediaSource = - new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); - MediaSource failingMediaSource = - new FakeMediaSource(/* timeline= */ null, ExoPlayerTestRunner.VIDEO_FORMAT) { - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - throw new IOException(); - } - }; - ConcatenatingMediaSource concatenatingMediaSource = - new ConcatenatingMediaSource(workingMediaSource, failingMediaSource); - FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(concatenatingMediaSource) - .setRenderers(renderer) - .build(); - try { - testRunner.start().blockUntilEnded(TIMEOUT_MS); - fail(); - } catch (ExoPlaybackException e) { - // Expected exception. - } - assertThat(renderer.sampleBufferReadCount).isAtLeast(1); - assertThat(renderer.hasReadStreamToEnd()).isTrue(); - } - - @Test - public void - testDynamicallyAddedSecondMediaSourceInPlaylistOnlyThrowsWhenPreviousPeriodIsFullyRead() - throws Exception { - Timeline fakeTimeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs= */ 10 * C.MICROS_PER_SECOND)); - MediaSource workingMediaSource = - new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); - MediaSource failingMediaSource = - new FakeMediaSource(/* timeline= */ null, ExoPlayerTestRunner.VIDEO_FORMAT) { - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - throw new IOException(); - } - }; - ConcatenatingMediaSource concatenatingMediaSource = - new ConcatenatingMediaSource(workingMediaSource); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .executeRunnable(() -> concatenatingMediaSource.addMediaSource(failingMediaSource)) - .play() - .build(); - FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(concatenatingMediaSource) - .setActionSchedule(actionSchedule) - .setRenderers(renderer) - .build(); - try { - testRunner.start().blockUntilEnded(TIMEOUT_MS); - fail(); - } catch (ExoPlaybackException e) { - // Expected exception. - } - assertThat(renderer.sampleBufferReadCount).isAtLeast(1); - assertThat(renderer.hasReadStreamToEnd()).isTrue(); - } - @Test public void removingLoopingLastPeriodFromPlaylistDoesNotThrow() throws Exception { Timeline timeline = @@ -7172,140 +7091,6 @@ public final class ExoPlayerTest { assertArrayEquals(new int[] {1, 0}, currentWindowIndices); } - // TODO(b/150584930): Fix reporting of renderer errors. - @Ignore - @Test - public void errorThrownDuringRendererEnableAtPeriodTransition_isReportedForNewPeriod() { - FakeMediaSource source1 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); - FakeMediaSource source2 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); - FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - FakeRenderer audioRenderer = - new FakeRenderer(C.TRACK_TYPE_AUDIO) { - @Override - protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) - throws ExoPlaybackException { - // Fail when enabling the renderer. This will happen during the period transition. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); - } - }; - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); - - assertThat(windowIndexAfterError.get()).isEqualTo(1); - assertThat(trackGroupsAfterError.get().length).isEqualTo(1); - assertThat(trackGroupsAfterError.get().get(0).getFormat(0)) - .isEqualTo(ExoPlayerTestRunner.AUDIO_FORMAT); - assertThat(trackSelectionsAfterError.get().get(0)).isNull(); // Video renderer. - assertThat(trackSelectionsAfterError.get().get(1)).isNotNull(); // Audio renderer. - } - - // TODO(b/150584930): Fix reporting of renderer errors. - @Ignore - @Test - public void errorThrownDuringRendererReplaceStreamAtPeriodTransition_isReportedForNewPeriod() { - FakeMediaSource source1 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), - ExoPlayerTestRunner.VIDEO_FORMAT, - ExoPlayerTestRunner.AUDIO_FORMAT); - FakeMediaSource source2 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); - FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - FakeRenderer audioRenderer = - new FakeRenderer(C.TRACK_TYPE_AUDIO) { - @Override - protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) - throws ExoPlaybackException { - // Fail when changing streams. This will happen during the period transition. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); - } - }; - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); - - assertThat(windowIndexAfterError.get()).isEqualTo(1); - assertThat(trackGroupsAfterError.get().length).isEqualTo(1); - assertThat(trackGroupsAfterError.get().get(0).getFormat(0)) - .isEqualTo(ExoPlayerTestRunner.AUDIO_FORMAT); - assertThat(trackSelectionsAfterError.get().get(0)).isNull(); // Video renderer. - assertThat(trackSelectionsAfterError.get().get(1)).isNotNull(); // Audio renderer. - } - @Test public void errorThrownDuringPlaylistUpdate_keepsConsistentPlayerState() { FakeMediaSource source1 = @@ -8218,6 +8003,174 @@ public final class ExoPlayerTest { exoPlayerTestRunner.assertMediaItemsTransitionedSame(initialMediaItem); } + @Test + public void + mediaSourceMaybeThrowSourceInfoRefreshError_isNotThrownUntilPlaybackReachedFailingItem() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + player.addMediaSource(new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + player.addMediaSource( + new FakeMediaSource(/* timeline= */ null) { + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw new IOException(); + } + }); + + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + } + + @Test + public void mediaPeriodMaybeThrowPrepareError_isNotThrownUntilPlaybackReachedFailingItem() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.addMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.addMediaSource( + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + return new FakeMediaPeriod( + trackGroupArray, + /* singleSampleTimeUs= */ 0, + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + drmEventDispatcher, + /* deferOnPrepared= */ true) { + @Override + public void maybeThrowPrepareError() throws IOException { + throw new IOException(); + } + }; + } + }); + + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + } + + @Test + public void sampleStreamMaybeThrowError_isNotThrownUntilPlaybackReachedFailingItem() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.addMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.addMediaSource( + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + return new FakeMediaPeriod( + trackGroupArray, /* singleSampleTimeUs= */ 0, mediaSourceEventDispatcher) { + @Override + protected SampleStream createSampleStream( + long positionUs, + TrackSelection selection, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher) { + return new FakeSampleStream( + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + drmEventDispatcher, + selection.getSelectedFormat(), + /* fakeSampleStreamItems= */ ImmutableList.of()) { + @Override + public void maybeThrowError() throws IOException { + throw new IOException(); + } + }; + } + }; + } + }); + + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + } + + @Test + public void rendererError_isReportedWithReadingMediaPeriodId() throws Exception { + FakeMediaSource source0 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource source1 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_VIDEO), + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + @Override + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + // Fail when enabling the renderer. This will happen during the period + // transition while the reading and playing period are different. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + ExoPlayer player = + new TestExoPlayer.Builder(context).setRenderersFactory(renderersFactory).build(); + player.setMediaSources(ImmutableList.of(source0, source1)); + player.prepare(); + player.play(); + + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + // Verify test setup by checking that playing period was indeed different. + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index b462d8617b..c94e39dd68 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -53,6 +53,7 @@ import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner; import com.google.android.exoplayer2.testutil.FakeAudioRenderer; import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.testutil.FakeRenderer; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.testutil.FakeVideoRenderer; @@ -1450,6 +1451,111 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period0); } + @Test + public void onPlayerError_thrownDuringRendererEnableAtPeriodTransition_isReportedForNewPeriod() + throws Exception { + FakeMediaSource source0 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource source1 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_VIDEO), + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + @Override + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + // Fail when enabling the renderer. This will happen during the period transition. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + + TestAnalyticsListener listener = + runAnalyticsTest( + new ConcatenatingMediaSource(source0, source1), + /* actionSchedule= */ null, + renderersFactory); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); + } + + @Test + public void onPlayerError_thrownDuringRenderAtPeriodTransition_isReportedForNewPeriod() + throws Exception { + FakeMediaSource source0 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource source1 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_VIDEO), + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + @Override + public void render(long positionUs, long realtimeUs) throws ExoPlaybackException { + // Fail when rendering the audio stream. This will happen during the period + // transition. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + + TestAnalyticsListener listener = + runAnalyticsTest( + new ConcatenatingMediaSource(source0, source1), + /* actionSchedule= */ null, + renderersFactory); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); + } + + @Test + public void + onPlayerError_thrownDuringRendererReplaceStreamAtPeriodTransition_isReportedForNewPeriod() + throws Exception { + FakeMediaSource source = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + private int streamChangeCount = 0; + + @Override + protected void onStreamChanged( + Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { + // Fail when changing streams for the second time. This will happen during the + // period transition (as the first time is when enabling the stream initially). + if (++streamChangeCount == 2) { + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + } + }; + + TestAnalyticsListener listener = + runAnalyticsTest( + new ConcatenatingMediaSource(source, source), + /* actionSchedule= */ null, + renderersFactory); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); + } + private void populateEventIds(Timeline timeline) { period0 = new EventWindowAndPeriodId( @@ -1508,6 +1614,14 @@ public final class AnalyticsCollectorTest { new FakeVideoRenderer(eventHandler, videoRendererEventListener), new FakeAudioRenderer(eventHandler, audioRendererEventListener) }; + return runAnalyticsTest(mediaSource, actionSchedule, renderersFactory); + } + + private static TestAnalyticsListener runAnalyticsTest( + MediaSource mediaSource, + @Nullable ActionSchedule actionSchedule, + RenderersFactory renderersFactory) + throws Exception { TestAnalyticsListener listener = new TestAnalyticsListener(); try { new ExoPlayerTestRunner.Builder(ApplicationProvider.getApplicationContext()) From bb787d662da6bdc8db42bda3c5cecee538dc55d1 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 15 Jul 2020 18:10:37 +0100 Subject: [PATCH 0690/1052] Ensure onAdPlaybackStarted is only called after the session is created We currently try to call onAdPlaybackStarted even if the ad session is not created yet and if not, we never call the callback afterwards. Make sure to update and create the current session before trying to send onAdPlaybackStarted. As a result, we can merge updateSessions into the existing handleTimelineChanged and handleDiscontinuity calls as they always need to be called together. PiperOrigin-RevId: 321383860 --- .../analytics/AnalyticsListener.java | 37 ++ .../DefaultPlaybackSessionManager.java | 48 ++- .../analytics/PlaybackSessionManager.java | 22 +- .../analytics/PlaybackStatsListener.java | 9 +- .../DefaultPlaybackSessionManagerTest.java | 348 ++++++++++-------- 5 files changed, 297 insertions(+), 167 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java index 1125e60690..7115ebaf5b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -36,6 +36,7 @@ import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.common.base.Objects; import java.io.IOException; /** @@ -155,6 +156,42 @@ public interface AnalyticsListener { this.currentPlaybackPositionMs = currentPlaybackPositionMs; this.totalBufferedDurationMs = totalBufferedDurationMs; } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EventTime eventTime = (EventTime) o; + return realtimeMs == eventTime.realtimeMs + && windowIndex == eventTime.windowIndex + && eventPlaybackPositionMs == eventTime.eventPlaybackPositionMs + && currentWindowIndex == eventTime.currentWindowIndex + && currentPlaybackPositionMs == eventTime.currentPlaybackPositionMs + && totalBufferedDurationMs == eventTime.totalBufferedDurationMs + && Objects.equal(timeline, eventTime.timeline) + && Objects.equal(mediaPeriodId, eventTime.mediaPeriodId) + && Objects.equal(currentTimeline, eventTime.currentTimeline) + && Objects.equal(currentMediaPeriodId, eventTime.currentMediaPeriodId); + } + + @Override + public int hashCode() { + return Objects.hashCode( + realtimeMs, + timeline, + windowIndex, + mediaPeriodId, + eventPlaybackPositionMs, + currentTimeline, + currentWindowIndex, + currentMediaPeriodId, + currentPlaybackPositionMs, + totalBufferedDurationMs); + } } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java index 04536bb6c1..b1ef52839b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.analytics; +import static com.google.android.exoplayer2.C.usToMs; +import static java.lang.Math.max; + import android.util.Base64; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -120,6 +123,38 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag if (currentSessionId == null) { currentSessionId = eventSession.sessionId; } + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + // Ensure that the content session for an ad session is created first. + MediaPeriodId contentMediaPeriodId = + new MediaPeriodId( + eventTime.mediaPeriodId.periodUid, + eventTime.mediaPeriodId.windowSequenceNumber, + eventTime.mediaPeriodId.adGroupIndex); + SessionDescriptor contentSession = + getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); + if (!contentSession.isCreated) { + contentSession.isCreated = true; + eventTime.timeline.getPeriodByUid(eventTime.mediaPeriodId.periodUid, period); + long adGroupPositionMs = + usToMs(period.getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex)) + + period.getPositionInWindowMs(); + // getAdGroupTimeUs may return 0 for prerolls despite period offset. + adGroupPositionMs = max(0, adGroupPositionMs); + EventTime eventTimeForContent = + new EventTime( + eventTime.realtimeMs, + eventTime.timeline, + eventTime.windowIndex, + contentMediaPeriodId, + /* eventPlaybackPositionMs= */ adGroupPositionMs, + eventTime.currentTimeline, + eventTime.currentWindowIndex, + eventTime.currentMediaPeriodId, + eventTime.currentPlaybackPositionMs, + eventTime.totalBufferedDurationMs); + listener.onSessionCreated(eventTimeForContent, contentSession.sessionId); + } + } if (!eventSession.isCreated) { eventSession.isCreated = true; listener.onSessionCreated(eventTime, eventSession.sessionId); @@ -131,7 +166,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag } @Override - public synchronized void handleTimelineUpdate(EventTime eventTime) { + public synchronized void updateSessionsWithTimelineChange(EventTime eventTime) { Assertions.checkNotNull(listener); Timeline previousTimeline = currentTimeline; currentTimeline = eventTime.timeline; @@ -149,11 +184,11 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag } } } - handlePositionDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); + updateSessionsWithDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); } @Override - public synchronized void handlePositionDiscontinuity( + public synchronized void updateSessionsWithDiscontinuity( EventTime eventTime, @DiscontinuityReason int reason) { Assertions.checkNotNull(listener); boolean hasAutomaticTransition = @@ -179,6 +214,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag SessionDescriptor currentSessionDescriptor = getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); currentSessionId = currentSessionDescriptor.sessionId; + updateSessions(eventTime); if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd() && (previousSessionDescriptor == null @@ -195,10 +231,8 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); SessionDescriptor contentSession = getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); - if (contentSession.isCreated && currentSessionDescriptor.isCreated) { - listener.onAdPlaybackStarted( - eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); - } + listener.onAdPlaybackStarted( + eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java index 7045779125..1038f3b6e1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -99,24 +99,34 @@ public interface PlaybackSessionManager { /** * Updates or creates sessions based on a player {@link EventTime}. * + *

        Call {@link #updateSessionsWithTimelineChange(EventTime)} or {@link + * #updateSessionsWithDiscontinuity(EventTime, int)} if the event is a {@link Timeline} change or + * a position discontinuity respectively. + * * @param eventTime The {@link EventTime}. */ void updateSessions(EventTime eventTime); /** - * Updates the session associations to a new timeline. + * Updates or creates sessions based on a {@link Timeline} change at {@link EventTime}. * - * @param eventTime The event time with the timeline change. + *

        Should be called instead of {@link #updateSessions(EventTime)} if a {@link Timeline} change + * occurred. + * + * @param eventTime The {@link EventTime} with the timeline change. */ - void handleTimelineUpdate(EventTime eventTime); + void updateSessionsWithTimelineChange(EventTime eventTime); /** - * Handles a position discontinuity. + * Updates or creates sessions based on a position discontinuity at {@link EventTime}. * - * @param eventTime The event time of the position discontinuity. + *

        Should be called instead of {@link #updateSessions(EventTime)} if a position discontinuity + * occurred. + * + * @param eventTime The {@link EventTime} of the position discontinuity. * @param reason The {@link DiscontinuityReason}. */ - void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); + void updateSessionsWithDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); /** * Finishes all existing sessions and calls their respective {@link diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index 662cb0b8a1..d6f8d17d31 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -284,8 +284,7 @@ public final class PlaybackStatsListener @Override public void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason int reason) { - sessionManager.handleTimelineUpdate(eventTime); - maybeAddSession(eventTime); + sessionManager.updateSessionsWithTimelineChange(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime, /* isSeek= */ false); @@ -295,8 +294,10 @@ public final class PlaybackStatsListener @Override public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) { - sessionManager.handlePositionDiscontinuity(eventTime, reason); - maybeAddSession(eventTime); + boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; + if (!isCompletelyIdle) { + sessionManager.updateSessionsWithDiscontinuity(eventTime, reason); + } if (reason == Player.DISCONTINUITY_REASON_SEEK) { onSeekStartedCalled = false; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java index a5a021c80a..08b784dee3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -39,6 +40,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -160,26 +162,44 @@ public final class DefaultPlaybackSessionManagerTest { @Test public void updateSessions_ofSameWindow_withoutMediaPeriodId_afterAd_doesNotCreateNewSession() { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaPeriodId mediaPeriodId = + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ new Object(), + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000, + FakeTimeline.createAdPlaybackState( + /* adsPerGroup= */ 1, /* adGroupTimesUs... */ 0))); + MediaPeriodId adMediaPeriodId = new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 0), /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 0); - EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); - EventTime eventTime2 = + MediaPeriodId contentMediaPeriodIdDuringAd = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0); + EventTime adEventTime = createEventTime(timeline, /* windowIndex= */ 0, adMediaPeriodId); + EventTime contentEventTimeDuringAd = + createEventTime( + timeline, /* windowIndex= */ 0, contentMediaPeriodIdDuringAd, adMediaPeriodId); + EventTime contentEventTimeWithoutMediaPeriodId = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); - sessionManager.updateSessions(eventTime1); - sessionManager.updateSessions(eventTime2); + sessionManager.updateSessions(adEventTime); + sessionManager.updateSessions(contentEventTimeWithoutMediaPeriodId); - ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); - verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); - verify(mockListener).onSessionActive(eventTime1, sessionId.getValue()); + verify(mockListener).onSessionCreated(eq(contentEventTimeDuringAd), anyString()); + ArgumentCaptor adSessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(adEventTime), adSessionId.capture()); + verify(mockListener).onSessionActive(adEventTime, adSessionId.getValue()); verifyNoMoreInteractions(mockListener); - assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) - .isEqualTo(sessionId.getValue()); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, adMediaPeriodId)) + .isEqualTo(adSessionId.getValue()); } @Test @@ -350,18 +370,6 @@ public final class DefaultPlaybackSessionManagerTest { verifyNoMoreInteractions(mockListener); } - @Test - public void getSessionForMediaPeriodId_returnsValue_butDoesNotCreateSession() { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaPeriodId mediaPeriodId = - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); - String session = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); - - assertThat(session).isNotEmpty(); - verifyNoMoreInteractions(mockListener); - } - @Test public void updateSessions_afterSessionForMediaPeriodId_withSameMediaPeriodId_returnsSameValue() { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); @@ -399,6 +407,81 @@ public final class DefaultPlaybackSessionManagerTest { assertThat(sessionId.getValue()).isEqualTo(expectedSessionId); } + @Test + public void + updateSessions_withNewAd_afterDiscontinuitiesFromContentToAdAndBack_doesNotActivateNewAd() { + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs =*/ 10 * C.MICROS_PER_SECOND, + new AdPlaybackState( + /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); + EventTime adEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime adEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0)); + EventTime contentEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 1)); + sessionManager.updateSessionsWithTimelineChange(contentEventTime1); + sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessionsWithDiscontinuity( + adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessionsWithDiscontinuity( + contentEventTime2, Player.DISCONTINUITY_REASON_AD_INSERTION); + String adSessionId2 = + sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); + + sessionManager.updateSessions(adEventTime2); + + verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); + } + + @Test + public void getSessionForMediaPeriodId_returnsValue_butDoesNotCreateSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + String session = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); + + assertThat(session).isNotEmpty(); + verifyNoMoreInteractions(mockListener); + } + @Test public void belongsToSession_withSameWindowIndex_returnsTrue() { EventTime eventTime = @@ -465,28 +548,38 @@ public final class DefaultPlaybackSessionManagerTest { @Test public void belongsToSession_withAd_returnsFalse() { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaPeriodId mediaPeriodId1 = + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ new Object(), + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000, + FakeTimeline.createAdPlaybackState( + /* adsPerGroup= */ 1, /* adGroupTimesUs... */ 0))); + MediaPeriodId contentMediaPeriodId = new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); - MediaPeriodId mediaPeriodId2 = + MediaPeriodId adMediaPeriodId = new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 0), /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 1); - EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); - EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); - sessionManager.updateSessions(eventTime1); - sessionManager.updateSessions(eventTime2); + EventTime contentEventTime = + createEventTime(timeline, /* windowIndex= */ 0, contentMediaPeriodId); + EventTime adEventTime = createEventTime(timeline, /* windowIndex= */ 0, adMediaPeriodId); + sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessions(adEventTime); ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); - verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); - verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); - assertThat(sessionManager.belongsToSession(eventTime2, sessionId1.getValue())).isFalse(); - assertThat(sessionManager.belongsToSession(eventTime1, sessionId2.getValue())).isFalse(); - assertThat(sessionManager.belongsToSession(eventTime2, sessionId2.getValue())).isTrue(); + verify(mockListener).onSessionCreated(eq(contentEventTime), sessionId1.capture()); + verify(mockListener).onSessionCreated(eq(adEventTime), sessionId2.capture()); + assertThat(sessionManager.belongsToSession(adEventTime, sessionId1.getValue())).isFalse(); + assertThat(sessionManager.belongsToSession(contentEventTime, sessionId2.getValue())).isFalse(); + assertThat(sessionManager.belongsToSession(adEventTime, sessionId2.getValue())).isTrue(); } @Test @@ -501,8 +594,7 @@ public final class DefaultPlaybackSessionManagerTest { EventTime newTimelineEventTime = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); - sessionManager.handleTimelineUpdate(newTimelineEventTime); - sessionManager.updateSessions(newTimelineEventTime); + sessionManager.updateSessionsWithTimelineChange(newTimelineEventTime); ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); @@ -545,8 +637,7 @@ public final class DefaultPlaybackSessionManagerTest { new MediaPeriodId( initialTimeline.getUidOfPeriod(/* periodIndex= */ 3), /* windowSequenceNumber= */ 2)); - sessionManager.handleTimelineUpdate(eventForInitialTimelineId100); - sessionManager.updateSessions(eventForInitialTimelineId100); + sessionManager.updateSessionsWithTimelineChange(eventForInitialTimelineId100); sessionManager.updateSessions(eventForInitialTimelineId200); sessionManager.updateSessions(eventForInitialTimelineId300); String sessionId100 = @@ -578,7 +669,7 @@ public final class DefaultPlaybackSessionManagerTest { timelineUpdate.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 2)); - sessionManager.handleTimelineUpdate(eventForTimelineUpdateId100); + sessionManager.updateSessionsWithTimelineChange(eventForTimelineUpdateId100); String updatedSessionId100 = sessionManager.getSessionForMediaPeriodId( timelineUpdate, eventForTimelineUpdateId100.mediaPeriodId); @@ -632,7 +723,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.updateSessions(contentEventTime); sessionManager.updateSessions(adEventTime); - sessionManager.handleTimelineUpdate(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -653,13 +744,11 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 0, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eq(eventTime1), anyString()); verify(mockListener).onSessionActive(eq(eventTime1), anyString()); @@ -681,17 +770,15 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 1, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); String sessionId1 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); String sessionId2 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -717,16 +804,14 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 1, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); String sessionId1 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); String sessionId2 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(eventTime2); + sessionManager.updateSessionsWithDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -748,12 +833,10 @@ public final class DefaultPlaybackSessionManagerTest { timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); - sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(eventTime2); + sessionManager.updateSessionsWithDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -785,7 +868,7 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 3, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 3), /* windowSequenceNumber= */ 3)); - sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime1); sessionManager.updateSessions(eventTime2); sessionManager.updateSessions(eventTime3); @@ -795,8 +878,7 @@ public final class DefaultPlaybackSessionManagerTest { String sessionId2 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(eventTime3); + sessionManager.updateSessionsWithDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -842,7 +924,20 @@ public final class DefaultPlaybackSessionManagerTest { /* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 0)); - EventTime contentEventTime = + EventTime contentEventTimeDuringPreroll = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + /* eventMediaPeriodId= */ new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0), + /* currentMediaPeriodId= */ new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTimeBetweenAds = createEventTime( adTimeline, /* windowIndex= */ 0, @@ -850,25 +945,31 @@ public final class DefaultPlaybackSessionManagerTest { adTimeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ 1)); - sessionManager.handleTimelineUpdate(adEventTime1); - sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessionsWithTimelineChange(adEventTime1); sessionManager.updateSessions(adEventTime2); String adSessionId1 = sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime1.mediaPeriodId); + String contentSessionId = + sessionManager.getSessionForMediaPeriodId( + adTimeline, contentEventTimeDuringPreroll.mediaPeriodId); - sessionManager.handlePositionDiscontinuity( - contentEventTime, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessionsWithDiscontinuity( + contentEventTimeBetweenAds, Player.DISCONTINUITY_REASON_AD_INSERTION); - verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); - verify(mockListener).onSessionActive(adEventTime1, adSessionId1); - verify(mockListener).onSessionCreated(eq(adEventTime2), anyString()); - verify(mockListener) + InOrder inOrder = inOrder(mockListener); + inOrder.verify(mockListener).onSessionCreated(contentEventTimeDuringPreroll, contentSessionId); + inOrder.verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); + inOrder.verify(mockListener).onSessionActive(adEventTime1, adSessionId1); + inOrder.verify(mockListener).onAdPlaybackStarted(adEventTime1, contentSessionId, adSessionId1); + inOrder.verify(mockListener).onSessionCreated(eq(adEventTime2), anyString()); + inOrder + .verify(mockListener) .onSessionFinished( - contentEventTime, adSessionId1, /* automaticTransitionToNextPlayback= */ true); - verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); - verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); - verifyNoMoreInteractions(mockListener); + contentEventTimeBetweenAds, + adSessionId1, + /* automaticTransitionToNextPlayback= */ true); + inOrder.verify(mockListener).onSessionActive(eq(contentEventTimeBetweenAds), anyString()); + inOrder.verifyNoMoreInteractions(); } @Test @@ -911,14 +1012,12 @@ public final class DefaultPlaybackSessionManagerTest { adTimeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ 0)); - sessionManager.handleTimelineUpdate(contentEventTime); - sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); sessionManager.updateSessions(adEventTime1); sessionManager.updateSessions(adEventTime2); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(adEventTime1); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -962,8 +1061,7 @@ public final class DefaultPlaybackSessionManagerTest { adTimeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ 1)); - sessionManager.handleTimelineUpdate(contentEventTime); - sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); sessionManager.updateSessions(adEventTime1); sessionManager.updateSessions(adEventTime2); String contentSessionId = @@ -973,11 +1071,9 @@ public final class DefaultPlaybackSessionManagerTest { String adSessionId2 = sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(adEventTime1); - sessionManager.handlePositionDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(adEventTime2); + sessionManager.updateSessionsWithDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); @@ -993,72 +1089,6 @@ public final class DefaultPlaybackSessionManagerTest { verifyNoMoreInteractions(mockListener); } - @Test - public void - updateSessions_withNewAd_afterDiscontinuitiesFromContentToAdAndBack_doesNotActivateNewAd() { - Timeline adTimeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* periodCount= */ 1, - /* id= */ 0, - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - new AdPlaybackState( - /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) - .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) - .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); - EventTime adEventTime1 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* adGroupIndex= */ 0, - /* adIndexInAdGroup= */ 0, - /* windowSequenceNumber= */ 0)); - EventTime adEventTime2 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* adGroupIndex= */ 1, - /* adIndexInAdGroup= */ 0, - /* windowSequenceNumber= */ 0)); - EventTime contentEventTime1 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* windowSequenceNumber= */ 0, - /* nextAdGroupIndex= */ 0)); - EventTime contentEventTime2 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* windowSequenceNumber= */ 0, - /* nextAdGroupIndex= */ 1)); - sessionManager.handleTimelineUpdate(contentEventTime1); - sessionManager.updateSessions(contentEventTime1); - sessionManager.updateSessions(adEventTime1); - sessionManager.handlePositionDiscontinuity( - adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(adEventTime1); - sessionManager.handlePositionDiscontinuity( - contentEventTime2, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(contentEventTime2); - String adSessionId2 = - sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); - - sessionManager.updateSessions(adEventTime2); - - verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); - } - @Test public void finishAllSessions_callsOnSessionFinishedForAllCreatedSessions() { Timeline timeline = new FakeTimeline(/* windowCount= */ 4); @@ -1098,4 +1128,22 @@ public final class DefaultPlaybackSessionManagerTest { /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); } + + private static EventTime createEventTime( + Timeline timeline, + int windowIndex, + @Nullable MediaPeriodId eventMediaPeriodId, + @Nullable MediaPeriodId currentMediaPeriodId) { + return new EventTime( + /* realtimeMs = */ 0, + timeline, + windowIndex, + eventMediaPeriodId, + /* eventPlaybackPositionMs= */ 0, + timeline, + windowIndex, + currentMediaPeriodId, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + } } From cfef1378a724faa8dfb221565f51fe9cf5ca0c28 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 15 Jul 2020 22:15:02 +0100 Subject: [PATCH 0691/1052] Fix JavaDoc PiperOrigin-RevId: 321436812 --- .../src/main/java/com/google/android/exoplayer2/Player.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 49f50466d2..9a02c07242 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -795,9 +795,9 @@ public interface Player { /** Playback skipped to a new media item (for example after failure). */ int MEDIA_ITEM_TRANSITION_REASON_SKIP = 3; /** - * The current media item has changed because of a modification of the timeline. This can either - * be if the period previously being played has been removed, or when the timeline becomes - * non-empty after being empty. + * The current media item has changed because of a change in the playlist. This can either be if + * the media item previously being played has been removed, or when the playlist becomes non-empty + * after being empty. */ int MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED = 4; From 6b4abf264396f5e6744109ec3d8aed6e64e25ac4 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 15 Jul 2020 22:33:41 +0100 Subject: [PATCH 0692/1052] Audio event consistency cleanup PiperOrigin-RevId: 321440594 --- .../audio/AudioRendererEventListener.java | 8 +++--- .../audio/DecoderAudioRenderer.java | 26 +++++-------------- .../audio/MediaCodecAudioRenderer.java | 26 +++++-------------- 3 files changed, 17 insertions(+), 43 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java index 41153bd015..104467c91e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -140,11 +140,9 @@ public interface AudioRendererEventListener { } } - /** - * Invokes {@link AudioRendererEventListener#onAudioSinkUnderrun(int, long, long)}. - */ - public void audioTrackUnderrun(final int bufferSize, final long bufferSizeMs, - final long elapsedSinceLastFeedMs) { + /** Invokes {@link AudioRendererEventListener#onAudioSinkUnderrun(int, long, long)}. */ + public void underrun( + final int bufferSize, final long bufferSizeMs, final long elapsedSinceLastFeedMs) { if (handler != null) { handler.post( () -> diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index d3f5dff113..c428ae480c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.audio; import android.media.audiofx.Virtualizer; import android.os.Handler; import android.os.SystemClock; +import androidx.annotation.CallSuper; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.BaseRenderer; @@ -295,19 +296,10 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media } /** See {@link AudioSink.Listener#onPositionDiscontinuity()}. */ - protected void onAudioTrackPositionDiscontinuity() { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onUnderrun(int, long, long)}. */ - protected void onAudioTrackUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onSkipSilenceEnabledChanged(boolean)}. */ - protected void onAudioTrackSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { - // Do nothing. + @CallSuper + protected void onPositionDiscontinuity() { + // We are out of sync so allow currentPositionUs to jump backwards. + allowPositionDiscontinuity = true; } /** @@ -686,21 +678,17 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media @Override public void onPositionDiscontinuity() { - onAudioTrackPositionDiscontinuity(); - // We are out of sync so allow currentPositionUs to jump backwards. - DecoderAudioRenderer.this.allowPositionDiscontinuity = true; + DecoderAudioRenderer.this.onPositionDiscontinuity(); } @Override public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); - onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + eventDispatcher.underrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } @Override public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); - onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 34a19652b8..b07e50e0c7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -22,6 +22,7 @@ import android.media.MediaCrypto; import android.media.MediaFormat; import android.media.audiofx.Virtualizer; import android.os.Handler; +import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -481,19 +482,10 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } /** See {@link AudioSink.Listener#onPositionDiscontinuity()}. */ - protected void onAudioTrackPositionDiscontinuity() { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onUnderrun(int, long, long)}. */ - protected void onAudioTrackUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onSkipSilenceEnabledChanged(boolean)}. */ - protected void onAudioTrackSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { - // Do nothing. + @CallSuper + protected void onPositionDiscontinuity() { + // We are out of sync so allow currentPositionUs to jump backwards. + allowPositionDiscontinuity = true; } @Override @@ -860,21 +852,17 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override public void onPositionDiscontinuity() { - onAudioTrackPositionDiscontinuity(); - // We are out of sync so allow currentPositionUs to jump backwards. - MediaCodecAudioRenderer.this.allowPositionDiscontinuity = true; + MediaCodecAudioRenderer.this.onPositionDiscontinuity(); } @Override public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); - onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + eventDispatcher.underrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } @Override public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); - onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } @Override From 26db5be49af41623bb851ecbaf06384c97e35af7 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 16 Jul 2020 00:27:59 +0100 Subject: [PATCH 0693/1052] DefaultAudioSink: Misc cleanup - Move output channel workaround to a block that's only executed for PCM - Remove redundant variable PiperOrigin-RevId: 321460898 --- .../exoplayer2/audio/DefaultAudioSink.java | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 2de6ee520c..4060e08726 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -468,17 +468,7 @@ public final class DefaultAudioSink implements AudioSink { @Override public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { - if (Util.SDK_INT < 21 && inputFormat.channelCount == 8 && outputChannels == null) { - // AudioTrack doesn't support 8 channel output before Android L. Discard the last two (side) - // channels to give a 6 channel stream that is supported. - outputChannels = new int[6]; - for (int i = 0; i < outputChannels.length; i++) { - outputChannels[i] = i; - } - } - boolean isInputPcm = Util.isEncodingLinearPcm(inputFormat.encoding); - boolean processingEnabled = isInputPcm; int sampleRate = inputFormat.sampleRate; int channelCount = inputFormat.channelCount; @C.Encoding int encoding = inputFormat.encoding; @@ -486,10 +476,20 @@ public final class DefaultAudioSink implements AudioSink { enableFloatOutput && Util.isEncodingHighResolutionPcm(inputFormat.encoding); AudioProcessor[] availableAudioProcessors = useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; - if (processingEnabled) { + if (isInputPcm) { trimmingAudioProcessor.setTrimFrameCount( inputFormat.encoderDelay, inputFormat.encoderPadding); + + if (Util.SDK_INT < 21 && inputFormat.channelCount == 8 && outputChannels == null) { + // AudioTrack doesn't support 8 channel output before Android L. Discard the last two (side) + // channels to give a 6 channel stream that is supported. + outputChannels = new int[6]; + for (int i = 0; i < outputChannels.length; i++) { + outputChannels[i] = i; + } + } channelMappingAudioProcessor.setChannelMap(outputChannels); + AudioProcessor.AudioFormat outputFormat = new AudioProcessor.AudioFormat(sampleRate, channelCount, encoding); for (AudioProcessor audioProcessor : availableAudioProcessors) { @@ -518,7 +518,7 @@ public final class DefaultAudioSink implements AudioSink { : C.LENGTH_UNSET; int outputPcmFrameSize = isInputPcm ? Util.getPcmFrameSize(encoding, channelCount) : C.LENGTH_UNSET; - boolean canApplyPlaybackParameters = processingEnabled && !useFloatOutput; + boolean canApplyPlaybackParameters = isInputPcm && !useFloatOutput; boolean useOffload = enableOffload && !isInputPcm @@ -540,7 +540,6 @@ public final class DefaultAudioSink implements AudioSink { outputChannelConfig, encoding, specifiedBufferSize, - processingEnabled, canApplyPlaybackParameters, availableAudioProcessors, inputFormat.encoderDelay, @@ -900,8 +899,7 @@ public final class DefaultAudioSink implements AudioSink { private boolean drainToEndOfStream() throws WriteException { boolean audioProcessorNeedsEndOfStream = false; if (drainingAudioProcessorIndex == C.INDEX_UNSET) { - drainingAudioProcessorIndex = - configuration.processingEnabled ? 0 : activeAudioProcessors.length; + drainingAudioProcessorIndex = configuration.isInputPcm ? 0 : activeAudioProcessors.length; audioProcessorNeedsEndOfStream = true; } while (drainingAudioProcessorIndex < activeAudioProcessors.length) { @@ -1622,7 +1620,6 @@ public final class DefaultAudioSink implements AudioSink { public final int outputChannelConfig; @C.Encoding public final int outputEncoding; public final int bufferSize; - public final boolean processingEnabled; public final boolean canApplyPlaybackParameters; public final AudioProcessor[] availableAudioProcessors; public int trimStartFrames; @@ -1638,7 +1635,6 @@ public final class DefaultAudioSink implements AudioSink { int outputChannelConfig, int outputEncoding, int specifiedBufferSize, - boolean processingEnabled, boolean canApplyPlaybackParameters, AudioProcessor[] availableAudioProcessors, int trimStartFrames, @@ -1651,7 +1647,6 @@ public final class DefaultAudioSink implements AudioSink { this.outputSampleRate = outputSampleRate; this.outputChannelConfig = outputChannelConfig; this.outputEncoding = outputEncoding; - this.processingEnabled = processingEnabled; this.canApplyPlaybackParameters = canApplyPlaybackParameters; this.availableAudioProcessors = availableAudioProcessors; this.trimStartFrames = trimStartFrames; From 79c14cb80180d23a9097f634078d7538f8e679ef Mon Sep 17 00:00:00 2001 From: insun Date: Thu, 16 Jul 2020 08:31:31 +0100 Subject: [PATCH 0694/1052] Renamed PlayerActivity2 to InternalPlayerActivity and refactored - Rename PlayerActivity2 to InternalPlayerActivity. - Reduced code duplications. PiperOrigin-RevId: 321515231 --- .../exoplayer2/demo/PlayerActivity.java | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index d15da29565..bf203159f9 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -85,14 +85,14 @@ public class PlayerActivity extends AppCompatActivity DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); } - private StyledPlayerView playerView; - private LinearLayout debugRootView; - private Button selectTracksButton; - private TextView debugTextView; - private boolean isShowingTrackSelectionDialog; + protected StyledPlayerView playerView; + protected LinearLayout debugRootView; + protected TextView debugTextView; + protected SimpleExoPlayer player; + private boolean isShowingTrackSelectionDialog; + private Button selectTracksButton; private DataSource.Factory dataSourceFactory; - private SimpleExoPlayer player; private List mediaItems; private DefaultTrackSelector trackSelector; private DefaultTrackSelector.Parameters trackSelectorParameters; @@ -122,7 +122,7 @@ public class PlayerActivity extends AppCompatActivity CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); } - setContentView(R.layout.player_activity); + setContentView(); debugRootView = findViewById(R.id.controls_root); debugTextView = findViewById(R.id.debug_text_view); selectTracksButton = findViewById(R.id.select_tracks_button); @@ -292,7 +292,11 @@ public class PlayerActivity extends AppCompatActivity // Internal methods - private void initializePlayer() { + protected void setContentView() { + setContentView(R.layout.player_activity); + } + + protected void initializePlayer() { if (player == null) { Intent intent = getIntent(); @@ -393,7 +397,7 @@ public class PlayerActivity extends AppCompatActivity return mediaItems; } - private void releasePlayer() { + protected void releasePlayer() { if (player != null) { updateTrackSelectorParameters(); updateStartPosition(); @@ -432,14 +436,14 @@ public class PlayerActivity extends AppCompatActivity } } - private void clearStartPosition() { + protected void clearStartPosition() { startAutoPlay = true; startWindow = C.INDEX_UNSET; startPosition = C.TIME_UNSET; } /** Returns a new DataSource factory. */ - private DataSource.Factory buildDataSourceFactory() { + protected DataSource.Factory buildDataSourceFactory() { return ((DemoApplication) getApplication()).buildDataSourceFactory(); } From 363a2a3b45488c53f12c8382d3c6a8b41ac5866f Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 16 Jul 2020 13:00:31 +0100 Subject: [PATCH 0695/1052] DefaultRenderersFactory: Add setting to enable float output This also renders https://github.com/google/ExoPlayer/pull/7625 redundant. PiperOrigin-RevId: 321544195 --- .../ext/flac/LibflacAudioRenderer.java | 4 + .../ext/opus/LibopusAudioRenderer.java | 18 ++++ library/core/proguard-rules.txt | 6 +- .../exoplayer2/DefaultRenderersFactory.java | 89 ++++++++++++------- .../exoplayer2/audio/DefaultAudioSink.java | 15 ++-- 5 files changed, 90 insertions(+), 42 deletions(-) diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 22591de77e..18b05e8ecb 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -45,6 +45,8 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { } /** + * Creates an instance. + * * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. @@ -58,6 +60,8 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { } /** + * Creates an instance. + * * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index b406dd5aad..39d8a216d8 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -21,6 +21,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.DecoderAudioRenderer; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MimeTypes; @@ -44,6 +45,8 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { } /** + * Creates a new instance. + * * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. @@ -56,6 +59,21 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { super(eventHandler, eventListener, audioProcessors); } + /** + * Creates a new instance. + * + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + */ + public LibopusAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + super(eventHandler, eventListener, audioSink); + } + @Override public String getName() { return TAG; diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 9578bd869b..4fcfeb7162 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -31,15 +31,15 @@ } -dontnote com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer { - (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); + (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioSink); } -dontnote com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer { - (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); + (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioSink); } -dontnote com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer { - (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); + (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioSink); } # Constructors accessed via reflection in DefaultDataSource diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index 3913922c3c..28e863bb19 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -20,9 +20,10 @@ import android.media.MediaCodec; import android.os.Handler; import android.os.Looper; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.audio.AudioCapabilities; -import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.DefaultAudioSink.DefaultAudioProcessorChain; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; @@ -92,6 +93,7 @@ public class DefaultRenderersFactory implements RenderersFactory { private MediaCodecSelector mediaCodecSelector; private @MediaCodecRenderer.MediaCodecOperationMode int audioMediaCodecOperationMode; private @MediaCodecRenderer.MediaCodecOperationMode int videoMediaCodecOperationMode; + private boolean enableFloatOutput; private boolean enableOffload; /** @param context A {@link Context}. */ @@ -218,6 +220,22 @@ public class DefaultRenderersFactory implements RenderersFactory { return this; } + /** + * Sets whether floating point audio should be output when possible. + * + *

        Enabling floating point output disables audio processing, but may allow for higher quality + * audio output. + * + *

        The default value is {@code false}. + * + * @param enableFloatOutput Whether to enable use of floating point audio output, if available. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setEnableAudioFloatOutput(boolean enableFloatOutput) { + this.enableFloatOutput = enableFloatOutput; + return this; + } + /** * Sets whether audio should be played using the offload path. * @@ -272,16 +290,18 @@ public class DefaultRenderersFactory implements RenderersFactory { videoRendererEventListener, allowedVideoJoiningTimeMs, renderersList); - buildAudioRenderers( - context, - extensionRendererMode, - mediaCodecSelector, - enableDecoderFallback, - buildAudioProcessors(), - eventHandler, - audioRendererEventListener, - enableOffload, - renderersList); + @Nullable AudioSink audioSink = buildAudioSink(context, enableFloatOutput, enableOffload); + if (audioSink != null) { + buildAudioRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + audioSink, + eventHandler, + audioRendererEventListener, + renderersList); + } buildTextRenderers(context, textRendererOutput, eventHandler.getLooper(), extensionRendererMode, renderersList); buildMetadataRenderers(context, metadataRendererOutput, eventHandler.getLooper(), @@ -427,12 +447,9 @@ public class DefaultRenderersFactory implements RenderersFactory { * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder * initialization fails. This may result in using a decoder that is slower/less efficient than * the primary decoder. - * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio buffers - * before output. May be empty. + * @param audioSink A sink to which the renderers will output. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param enableOffload Whether to enable use of audio offload for supported formats, if - * available. * @param out An array to which the built renderers should be appended. */ protected void buildAudioRenderers( @@ -440,10 +457,9 @@ public class DefaultRenderersFactory implements RenderersFactory { @ExtensionRendererMode int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, - AudioProcessor[] audioProcessors, + AudioSink audioSink, Handler eventHandler, AudioRendererEventListener eventListener, - boolean enableOffload, ArrayList out) { MediaCodecAudioRenderer audioRenderer = new MediaCodecAudioRenderer( @@ -452,11 +468,7 @@ public class DefaultRenderersFactory implements RenderersFactory { enableDecoderFallback, eventHandler, eventListener, - new DefaultAudioSink( - AudioCapabilities.getCapabilities(context), - new DefaultAudioProcessorChain(audioProcessors), - /* enableFloatOutput= */ false, - enableOffload)); + audioSink); audioRenderer.experimental_setMediaCodecOperationMode(audioMediaCodecOperationMode); out.add(audioRenderer); @@ -476,10 +488,10 @@ public class DefaultRenderersFactory implements RenderersFactory { clazz.getConstructor( android.os.Handler.class, com.google.android.exoplayer2.audio.AudioRendererEventListener.class, - com.google.android.exoplayer2.audio.AudioProcessor[].class); + com.google.android.exoplayer2.audio.AudioSink.class); // LINT.ThenChange(../../../../../../../proguard-rules.txt) Renderer renderer = - (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + (Renderer) constructor.newInstance(eventHandler, eventListener, audioSink); out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibopusAudioRenderer."); } catch (ClassNotFoundException e) { @@ -497,10 +509,10 @@ public class DefaultRenderersFactory implements RenderersFactory { clazz.getConstructor( android.os.Handler.class, com.google.android.exoplayer2.audio.AudioRendererEventListener.class, - com.google.android.exoplayer2.audio.AudioProcessor[].class); + com.google.android.exoplayer2.audio.AudioSink.class); // LINT.ThenChange(../../../../../../../proguard-rules.txt) Renderer renderer = - (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + (Renderer) constructor.newInstance(eventHandler, eventListener, audioSink); out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibflacAudioRenderer."); } catch (ClassNotFoundException e) { @@ -519,10 +531,10 @@ public class DefaultRenderersFactory implements RenderersFactory { clazz.getConstructor( android.os.Handler.class, com.google.android.exoplayer2.audio.AudioRendererEventListener.class, - com.google.android.exoplayer2.audio.AudioProcessor[].class); + com.google.android.exoplayer2.audio.AudioSink.class); // LINT.ThenChange(../../../../../../../proguard-rules.txt) Renderer renderer = - (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + (Renderer) constructor.newInstance(eventHandler, eventListener, audioSink); out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded FfmpegAudioRenderer."); } catch (ClassNotFoundException e) { @@ -595,10 +607,23 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Builds an array of {@link AudioProcessor}s that will process PCM audio before output. + * Builds an {@link AudioSink} to which the audio renderers will output. + * + * @param context The {@link Context} associated with the player. + * @param enableFloatOutput Whether to enable use of floating point audio output, if available. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. + * @return The {@link AudioSink} to which the audio renderers will output. May be {@code null} if + * no audio renderers are required. If {@code null} is returned then {@link + * #buildAudioRenderers} will not be called. */ - protected AudioProcessor[] buildAudioProcessors() { - return new AudioProcessor[0]; + @Nullable + protected AudioSink buildAudioSink( + Context context, boolean enableFloatOutput, boolean enableOffload) { + return new DefaultAudioSink( + AudioCapabilities.getCapabilities(context), + new DefaultAudioProcessorChain(), + enableFloatOutput, + enableOffload); } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 4060e08726..1263a46ffe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -365,14 +365,15 @@ public final class DefaultAudioSink implements AudioSink { * parameters adjustments. The instance passed in must not be reused in other sinks. * @param enableFloatOutput Whether to enable 32-bit float output. Where possible, 32-bit float * output will be used if the input is 32-bit float, and also if the input is high resolution - * (24-bit or 32-bit) integer PCM. Audio processing (for example, speed adjustment) will not - * be available when float output is in use. - * @param enableOffload Whether audio offloading is enabled. If an audio format can be both played + * (24-bit or 32-bit) integer PCM. Float output is supported from API level 21. Audio + * processing (for example, speed adjustment) will not be available when float output is in + * use. + * @param enableOffload Whether to enable audio offload. If an audio format can be both played * with offload and encoded audio passthrough, it will be played in offload. Audio offload is - * supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android - * devices can only support one offload {@link android.media.AudioTrack} at a time and can - * invalidate it at any time. Thus an app can never be guaranteed that it will be able to play - * in offload. + * supported from API level 29. Most Android devices can only support one offload {@link + * android.media.AudioTrack} at a time and can invalidate it at any time. Thus an app can + * never be guaranteed that it will be able to play in offload. Audio processing (for example, + * speed adjustment) will not be available when offload is in use. */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, From ab95e3f38807d63774b253e7f4337dcfd71e31bb Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 16 Jul 2020 13:45:10 +0100 Subject: [PATCH 0696/1052] Migrate usages of deprecated SimpleCache constructors I duplicated some methods in SimpleCacheTest to ensure we keep testing the deprecated code paths for now. PiperOrigin-RevId: 321548802 --- .../upstream/cache/CacheDataSourceTest.java | 3 +- .../upstream/cache/CacheDataSourceTest2.java | 6 +- .../upstream/cache/CacheWriterTest.java | 3 +- .../upstream/cache/SimpleCacheTest.java | 79 ++++++++++++++++--- .../dash/offline/DashDownloaderTest.java | 3 +- .../dash/offline/DownloadManagerDashTest.java | 4 +- .../dash/offline/DownloadServiceDashTest.java | 3 +- .../source/hls/offline/HlsDownloaderTest.java | 4 +- .../playbacktests/gts/DashDownloadTest.java | 5 +- 9 files changed, 93 insertions(+), 17 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 652a5643a7..a94db25159 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -81,7 +81,8 @@ public final class CacheDataSourceTest { tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); upstreamDataSource = new FakeDataSource(); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java index 0a1f800983..e55d16541a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java @@ -156,7 +156,11 @@ public final class CacheDataSourceTest2 { private static CacheDataSource buildCacheDataSource(Context context, DataSource upstreamSource, boolean useAesEncryption) throws CacheException { File cacheDir = context.getExternalCacheDir(); - Cache cache = new SimpleCache(new File(cacheDir, EXO_CACHE_DIR), new NoOpCacheEvictor()); + Cache cache = + new SimpleCache( + new File(cacheDir, EXO_CACHE_DIR), + new NoOpCacheEvictor(), + TestUtil.getInMemoryDatabaseProvider()); emptyCache(cache); // Source and cipher diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java index d0cc42b062..cc688c4675 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java @@ -96,7 +96,8 @@ public final class CacheWriterTest { mockCache.init(); tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); } @After diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index fce14794eb..482c95bfd2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.doAnswer; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Util; @@ -38,7 +39,6 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; /** Unit tests for {@link SimpleCache}. */ @RunWith(AndroidJUnit4.class) @@ -50,18 +50,23 @@ public class SimpleCacheTest { private File testDir; private File cacheDir; + private DatabaseProvider databaseProvider; @Before - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); + public void createTestDir() throws Exception { testDir = Util.createTempFile(ApplicationProvider.getApplicationContext(), "SimpleCacheTest"); assertThat(testDir.delete()).isTrue(); assertThat(testDir.mkdirs()).isTrue(); cacheDir = new File(testDir, "cache"); } + @Before + public void createDatabaseProvider() { + databaseProvider = TestUtil.getInMemoryDatabaseProvider(); + } + @After - public void tearDown() { + public void deleteTestDir() { Util.recursiveDelete(testDir); } @@ -96,7 +101,33 @@ public class SimpleCacheTest { } @Test - public void newInstance_withExistingCacheDirectory_loadsCachedData() throws Exception { + @SuppressWarnings("deprecation") // Testing deprecated behaviour + public void newInstance_withExistingCacheDirectory_withoutDatabase_loadsCachedData() + throws Exception { + SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + + // Write some data and metadata to the cache. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setRedirectedUri(mutations, Uri.parse("https://redirect.google.com")); + simpleCache.applyContentMetadataMutations(KEY_1, mutations); + simpleCache.release(); + + // Create a new instance pointing to the same directory. + simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + + // Read the cached data and metadata back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertCachedDataReadCorrect(fileSpan); + assertThat(ContentMetadata.getRedirectedUri(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(Uri.parse("https://redirect.google.com")); + } + + @Test + public void newInstance_withExistingCacheDirectory_withDatabase_loadsCachedData() + throws Exception { SimpleCache simpleCache = getSimpleCache(); // Write some data and metadata to the cache. @@ -127,8 +158,10 @@ public class SimpleCacheTest { } @Test - public void newInstance_withExistingCacheDirectory_resolvesInconsistentState() throws Exception { - SimpleCache simpleCache = getSimpleCache(); + @SuppressWarnings("deprecation") // Testing deprecated behaviour + public void newInstance_withExistingCacheDirectory_withoutDatabase_resolvesInconsistentState() + throws Exception { + SimpleCache simpleCache = new SimpleCache(testDir, new NoOpCacheEvictor()); CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); @@ -149,6 +182,30 @@ public class SimpleCacheTest { } @Test + public void newInstance_withExistingCacheDirectory_withDatabase_resolvesInconsistentState() + throws Exception { + SimpleCache simpleCache = new SimpleCache(testDir, new NoOpCacheEvictor(), databaseProvider); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_1).first()); + + // Don't release the cache. This means the index file won't have been written to disk after the + // span was removed. Move the cache directory instead, so we can reload it without failing the + // folder locking check. + File cacheDir2 = new File(testDir, "cache2"); + cacheDir.renameTo(cacheDir2); + + // Create a new instance pointing to the new directory. + simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor(), databaseProvider); + + // The entry for KEY_1 should have been removed when the cache was reloaded. + assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); + } + + @Test + @SuppressWarnings("deprecation") // Encrypted index is deprecated public void newInstance_withEncryptedIndex() throws Exception { SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); @@ -165,6 +222,7 @@ public class SimpleCacheTest { } @Test + @SuppressWarnings("deprecation") // Encrypted index is deprecated public void newInstance_withEncryptedIndexAndWrongKey_clearsCache() throws Exception { SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); @@ -183,6 +241,7 @@ public class SimpleCacheTest { } @Test + @SuppressWarnings("deprecation") // Encrypted index is deprecated public void newInstance_withEncryptedIndexAndNoKey_clearsCache() throws Exception { SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); @@ -619,7 +678,7 @@ public class SimpleCacheTest { @Test public void usingReleasedCache_throwsException() { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + SimpleCache simpleCache = getSimpleCache(); simpleCache.release(); assertThrows( IllegalStateException.class, @@ -627,9 +686,11 @@ public class SimpleCacheTest { } private SimpleCache getSimpleCache() { - return new SimpleCache(cacheDir, new NoOpCacheEvictor()); + return new SimpleCache(cacheDir, new NoOpCacheEvictor(), databaseProvider); } + @Deprecated + @SuppressWarnings("deprecation") // Testing deprecated behaviour. private SimpleCache getEncryptedSimpleCache(byte[] secretKey) { return new SimpleCache(cacheDir, new NoOpCacheEvictor(), secretKey); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java index 23fb9cbe3d..17c6da74dc 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java @@ -70,7 +70,8 @@ public class DashDownloaderTest { MockitoAnnotations.initMocks(this); tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); progressListener = new ProgressListener(); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index 76b7a76a66..d2756780f8 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -82,7 +82,9 @@ public class DownloadManagerDashTest { tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); File cacheFolder = new File(tempFolder, "cache"); cacheFolder.mkdir(); - cache = new SimpleCache(cacheFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache( + cacheFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); MockitoAnnotations.initMocks(this); fakeDataSet = new FakeDataSet() diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index 9a3e5a4aff..1257a70a07 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -79,7 +79,8 @@ public class DownloadServiceDashTest { testThread = new DummyMainThread(); context = ApplicationProvider.getApplicationContext(); tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); Runnable pauseAction = () -> { diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java index 7b5577d22d..1fc51ab498 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java @@ -47,6 +47,7 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; import com.google.android.exoplayer2.testutil.CacheAsserts.RequestSet; import com.google.android.exoplayer2.testutil.FakeDataSet; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; @@ -76,7 +77,8 @@ public class HlsDownloaderTest { public void setUp() throws Exception { tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); progressListener = new ProgressListener(); fakeDataSet = new FakeDataSet() diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java index 6e9e55e1c1..c14295b027 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java @@ -21,6 +21,7 @@ import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.rule.ActivityTestRule; import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.dash.DashUtil; import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; @@ -69,7 +70,9 @@ public final class DashDownloadTest { .setAudioVideoFormats( DashTestData.AAC_AUDIO_REPRESENTATION_ID, DashTestData.H264_CDD_FIXED); tempFolder = Util.createTempDirectory(testRule.getActivity(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache( + tempFolder, new NoOpCacheEvictor(), new ExoDatabaseProvider(testRule.getActivity())); httpDataSourceFactory = new DefaultHttpDataSourceFactory("ExoPlayer", null); offlineDataSourceFactory = new CacheDataSource.Factory().setCache(cache); } From c0204bfdc49d4cd57a5bef54d15c04d82d49f819 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 16 Jul 2020 14:30:00 +0100 Subject: [PATCH 0697/1052] Throw when window uid changes for unexpected reasons PiperOrigin-RevId: 321553397 --- .../main/java/com/google/android/exoplayer2/ExoPlayerImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 4520054b90..215e6a3528 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -1055,7 +1055,8 @@ import java.util.concurrent.TimeoutException; } else if (timelineChanged) { transitionReason = MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; } else { - transitionReason = MEDIA_ITEM_TRANSITION_REASON_SKIP; + // A change in window uid must be justified by one of the reasons above. + throw new IllegalStateException(); } return new Pair<>(/* isTransitioning */ true, transitionReason); } else if (positionDiscontinuity From b755df1338e9e3266ff883748a0d6f4dfad444c5 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 16 Jul 2020 14:32:31 +0100 Subject: [PATCH 0698/1052] Update PlayerWrapper methods to return void where possible Suggested during the review of https://github.com/google/ExoPlayer/commit/437d1b6e9ae1e085583b3ab8e8d686d1eea32b65 This keeps the Runnable -> Callable conversion encapsulated inside SessionPlayerConnector which makes it clearer why it's needed. PiperOrigin-RevId: 321553744 --- .../android/exoplayer2/ext/media2/PlayerWrapper.java | 6 ++---- .../exoplayer2/ext/media2/SessionPlayerConnector.java | 10 ++++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java index 82f50aceca..7e3e3b4ac5 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java @@ -305,11 +305,10 @@ import java.util.List; } } - public boolean setAudioAttributes(AudioAttributesCompat audioAttributes) { + public void setAudioAttributes(AudioAttributesCompat audioAttributes) { Player.AudioComponent audioComponent = Assertions.checkStateNotNull(player.getAudioComponent()); audioComponent.setAudioAttributes( Utils.getAudioAttributes(audioAttributes), /* handleAudioFocus= */ true); - return true; } public AudioAttributesCompat getAudioAttributes() { @@ -318,9 +317,8 @@ import java.util.List; audioComponent != null ? audioComponent.getAudioAttributes() : AudioAttributes.DEFAULT); } - public boolean setPlaybackSpeed(float playbackSpeed) { + public void setPlaybackSpeed(float playbackSpeed) { player.setPlaybackSpeed(playbackSpeed); - return true; } public float getPlaybackSpeed() { diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java index 8c0b1bfbb1..019963c305 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java @@ -178,14 +178,20 @@ public final class SessionPlayerConnector extends SessionPlayer { Assertions.checkArgument(playbackSpeed > 0f); return playerCommandQueue.addCommand( PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SPEED, - /* command= */ () -> player.setPlaybackSpeed(playbackSpeed)); + /* command= */ () -> { + player.setPlaybackSpeed(playbackSpeed); + return true; + }); } @Override public ListenableFuture setAudioAttributes(AudioAttributesCompat attr) { return playerCommandQueue.addCommand( PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES, - /* command= */ () -> player.setAudioAttributes(Assertions.checkNotNull(attr))); + /* command= */ () -> { + player.setAudioAttributes(Assertions.checkNotNull(attr)); + return true; + }); } @Override From 161dea661f7d83508fc4825dd55cbacf7e65d97a Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 16 Jul 2020 14:33:19 +0100 Subject: [PATCH 0699/1052] Assorted deprecation fixes/migrations/suppressions These were missed on the first pass PiperOrigin-RevId: 321553847 --- .../src/main/java/com/google/android/exoplayer2/C.java | 4 +++- .../java/com/google/android/exoplayer2/ExoPlayerTest.java | 5 ++--- .../android/exoplayer2/playbacktests/gts/DashTestRunner.java | 5 ++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index bcb57c7d3c..c4f4a2bbb5 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -579,7 +579,9 @@ public final class C { public static final int VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING = MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING; /** @deprecated Use {@code Renderer.VIDEO_SCALING_MODE_DEFAULT}. */ - @Deprecated public static final int VIDEO_SCALING_MODE_DEFAULT = VIDEO_SCALING_MODE_SCALE_TO_FIT; + @SuppressWarnings("deprecation") + @Deprecated + public static final int VIDEO_SCALING_MODE_DEFAULT = VIDEO_SCALING_MODE_SCALE_TO_FIT; /** * Track selection flags. Possible flag values are {@link #SELECTION_FLAG_DEFAULT}, {@link diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 4733637c82..25f94da208 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -35,7 +35,6 @@ import android.media.AudioManager; import android.net.Uri; import android.os.Looper; import android.view.Surface; -import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; @@ -8326,8 +8325,8 @@ public final class ExoPlayerTest { } @Override - public View[] getAdOverlayViews() { - return new View[0]; + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of(); } } } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java index c8c4f98a85..3d37d18182 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java @@ -19,13 +19,13 @@ import static com.google.android.exoplayer2.C.WIDEVINE_UUID; import android.media.MediaDrm; import android.media.UnsupportedSchemeException; -import android.net.Uri; import android.view.Surface; import android.widget.FrameLayout; import androidx.annotation.RequiresApi; import androidx.test.core.app.ApplicationProvider; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.decoder.DecoderCounters; @@ -308,11 +308,10 @@ import java.util.List; this.dataSourceFactory != null ? this.dataSourceFactory : new DefaultDataSourceFactory(host, userAgent); - Uri manifestUri = Uri.parse(manifestUrl); return new DashMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(MIN_LOADABLE_RETRY_COUNT)) - .createMediaSource(manifestUri); + .createMediaSource(MediaItem.fromUri(manifestUrl)); } @Override From 3b26c218e130a0c822c2427886cb16ad72fa8fff Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 16 Jul 2020 17:10:59 +0100 Subject: [PATCH 0700/1052] Deduplicate clear playlist code for stop(true) calls. The logic to clear the playlist is currently duplicated in various reset methods so that calls to player.stop(true) can clear the playlist. This can be deduplicated by clearing the playlist as a seperate operation that reuses the existing code. PiperOrigin-RevId: 321578759 --- .../exoplayer2/demo/PlayerActivity.java | 1 + .../android/exoplayer2/ExoPlayerImpl.java | 113 ++++++------------ .../exoplayer2/ExoPlayerImplInternal.java | 54 ++------- .../com/google/android/exoplayer2/Player.java | 10 +- .../exoplayer2/testutil/StubExoPlayer.java | 2 +- 5 files changed, 56 insertions(+), 124 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index bf203159f9..575b4bbce1 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -394,6 +394,7 @@ public class PlayerActivity extends AppCompatActivity if (!hasAds) { releaseAdsLoader(); } + mediaItems.add(0, MediaItem.fromUri("https://html5demos.com/assets/dizzy.mp4")); return mediaItems; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 215e6a3528..a7479d349b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -304,13 +304,10 @@ import java.util.concurrent.TimeoutException; if (playbackInfo.playbackState != Player.STATE_IDLE) { return; } - PlaybackInfo playbackInfo = - getResetPlaybackInfo( - /* clearPlaylist= */ false, - /* resetError= */ true, - /* playbackState= */ this.playbackInfo.timeline.isEmpty() - ? Player.STATE_ENDED - : Player.STATE_BUFFERING); + PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlaybackError(null); + playbackInfo = + playbackInfo.copyWithPlaybackState( + playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); // Trigger internal prepare first before updating the playback info and notifying external // listeners to ensure that new operations issued in the listener notifications reach the // player after this prepare. The internal player can't change the playback info immediately @@ -440,8 +437,14 @@ import java.util.concurrent.TimeoutException; @Override public void removeMediaItems(int fromIndex, int toIndex) { - Assertions.checkArgument(toIndex > fromIndex); - removeMediaItemsInternal(fromIndex, toIndex); + PlaybackInfo playbackInfo = removeMediaItemsInternal(fromIndex, toIndex); + updatePlaybackInfo( + playbackInfo, + /* positionDiscontinuity= */ false, + /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, + /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + /* seekProcessed= */ false); } @Override @@ -474,10 +477,7 @@ import java.util.concurrent.TimeoutException; @Override public void clearMediaItems() { - if (mediaSourceHolderSnapshots.isEmpty()) { - return; - } - removeMediaItemsInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolderSnapshots.size()); + removeMediaItems(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolderSnapshots.size()); } @Override @@ -690,17 +690,20 @@ import java.util.concurrent.TimeoutException; @Override public void stop(boolean reset) { - PlaybackInfo playbackInfo = - getResetPlaybackInfo( - /* clearPlaylist= */ reset, - /* resetError= */ reset, - /* playbackState= */ Player.STATE_IDLE); - // Trigger internal stop first before updating the playback info and notifying external - // listeners to ensure that new operations issued in the listener notifications reach the - // player after this stop. The internal player can't change the playback info immediately - // because it uses a callback. + PlaybackInfo playbackInfo; + if (reset) { + playbackInfo = + removeMediaItemsInternal( + /* fromIndex= */ 0, /* toIndex= */ mediaSourceHolderSnapshots.size()); + playbackInfo = playbackInfo.copyWithPlaybackError(null); + } else { + playbackInfo = this.playbackInfo.copyWithLoadingMediaPeriodId(this.playbackInfo.periodId); + playbackInfo.bufferedPositionUs = playbackInfo.positionUs; + playbackInfo.totalBufferedDurationUs = 0; + } + playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); pendingOperationAcks++; - internalPlayer.stop(reset); + internalPlayer.stop(); updatePlaybackInfo( playbackInfo, /* positionDiscontinuity= */ false, @@ -726,11 +729,10 @@ import java.util.concurrent.TimeoutException; if (analyticsCollector != null) { bandwidthMeter.removeEventListener(analyticsCollector); } - playbackInfo = - getResetPlaybackInfo( - /* clearPlaylist= */ false, - /* resetError= */ false, - /* playbackState= */ Player.STATE_IDLE); + playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(playbackInfo.periodId); + playbackInfo.bufferedPositionUs = playbackInfo.positionUs; + playbackInfo.totalBufferedDurationUs = 0; } @Override @@ -924,7 +926,9 @@ import java.util.concurrent.TimeoutException; if (!this.playbackInfo.timeline.isEmpty() && newTimeline.isEmpty()) { // Update the masking variables, which are used when the timeline becomes empty because a // ConcatenatingMediaSource has been cleared. - resetMaskingPosition(); + maskingWindowIndex = C.INDEX_UNSET; + maskingWindowPositionMs = 0; + maskingPeriodIndex = 0; } if (!newTimeline.isEmpty()) { List timelines = ((PlaylistTimeline) newTimeline).getChildTimelines(); @@ -945,41 +949,6 @@ import java.util.concurrent.TimeoutException; } } - private PlaybackInfo getResetPlaybackInfo( - boolean clearPlaylist, boolean resetError, @Player.State int playbackState) { - if (clearPlaylist) { - // Reset list of media source holders which are used for creating the masking timeline. - removeMediaSourceHolders( - /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolderSnapshots.size()); - resetMaskingPosition(); - } - Timeline timeline = playbackInfo.timeline; - MediaPeriodId mediaPeriodId = playbackInfo.periodId; - long requestedContentPositionUs = playbackInfo.requestedContentPositionUs; - long positionUs = playbackInfo.positionUs; - if (clearPlaylist) { - timeline = Timeline.EMPTY; - mediaPeriodId = PlaybackInfo.getDummyPeriodForEmptyTimeline(); - requestedContentPositionUs = C.TIME_UNSET; - positionUs = 0; - } - return new PlaybackInfo( - timeline, - mediaPeriodId, - requestedContentPositionUs, - playbackState, - resetError ? null : playbackInfo.playbackError, - /* isLoading= */ false, - clearPlaylist ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, - clearPlaylist ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, - mediaPeriodId, - playbackInfo.playWhenReady, - playbackInfo.playbackSuppressionReason, - positionUs, - /* totalBufferedDurationUs= */ 0, - positionUs); - } - private void updatePlaybackInfo( PlaybackInfo playbackInfo, boolean positionDiscontinuity, @@ -1139,7 +1108,7 @@ import java.util.concurrent.TimeoutException; return holders; } - private void removeMediaItemsInternal(int fromIndex, int toIndex) { + private PlaybackInfo removeMediaItemsInternal(int fromIndex, int toIndex) { Assertions.checkArgument( fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolderSnapshots.size()); int currentWindowIndex = getCurrentWindowIndex(); @@ -1164,13 +1133,7 @@ import java.util.concurrent.TimeoutException; newPlaybackInfo = newPlaybackInfo.copyWithPlaybackState(STATE_ENDED); } internalPlayer.removeMediaSources(fromIndex, toIndex, shuffleOrder); - updatePlaybackInfo( - newPlaybackInfo, - /* positionDiscontinuity= */ false, - /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, - /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - /* seekProcessed= */ false); + return newPlaybackInfo; } private void removeMediaSourceHolders(int fromIndex, int toIndexExclusive) { @@ -1389,12 +1352,6 @@ import java.util.concurrent.TimeoutException; } } - private void resetMaskingPosition() { - maskingWindowIndex = C.INDEX_UNSET; - maskingWindowPositionMs = 0; - maskingPeriodIndex = 0; - } - private long periodPositionUsToWindowPositionMs(MediaPeriodId periodId, long positionUs) { long positionMs = C.usToMs(positionUs); playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 07f5234e24..e785727aa7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -306,8 +306,8 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.obtainMessage(MSG_SET_SEEK_PARAMETERS, seekParameters).sendToTarget(); } - public void stop(boolean reset) { - handler.obtainMessage(MSG_STOP, reset ? 1 : 0, 0).sendToTarget(); + public void stop() { + handler.obtainMessage(MSG_STOP).sendToTarget(); } public void setMediaSources( @@ -475,10 +475,7 @@ import java.util.concurrent.atomic.AtomicBoolean; /* foregroundMode= */ msg.arg1 != 0, /* processedFlag= */ (AtomicBoolean) msg.obj); break; case MSG_STOP: - stopInternal( - /* forceResetRenderers= */ false, - /* resetPositionAndState= */ msg.arg1 != 0, - /* acknowledgeStop= */ true); + stopInternal(/* forceResetRenderers= */ false, /* acknowledgeStop= */ true); break; case MSG_PERIOD_PREPARED: handlePeriodPrepared((MediaPeriod) msg.obj); @@ -537,10 +534,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } } Log.e(TAG, "Playback error", e); - stopInternal( - /* forceResetRenderers= */ true, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); + stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); playbackInfo = playbackInfo.copyWithPlaybackError(e); maybeNotifyPlaybackInfoChanged(); } catch (IOException e) { @@ -551,10 +545,7 @@ import java.util.concurrent.atomic.AtomicBoolean; error = error.copyWithMediaPeriodId(playingPeriod.info.id); } Log.e(TAG, "Playback error", error); - stopInternal( - /* forceResetRenderers= */ false, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); + stopInternal(/* forceResetRenderers= */ false, /* acknowledgeStop= */ false); playbackInfo = playbackInfo.copyWithPlaybackError(error); maybeNotifyPlaybackInfoChanged(); } catch (RuntimeException | OutOfMemoryError e) { @@ -563,10 +554,7 @@ import java.util.concurrent.atomic.AtomicBoolean; ? ExoPlaybackException.createForOutOfMemoryError((OutOfMemoryError) e) : ExoPlaybackException.createForUnexpected((RuntimeException) e); Log.e(TAG, "Playback error", error); - stopInternal( - /* forceResetRenderers= */ true, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); + stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); playbackInfo = playbackInfo.copyWithPlaybackError(error); maybeNotifyPlaybackInfoChanged(); } @@ -647,7 +635,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetRenderers= */ false, /* resetPosition= */ false, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); loadControl.onPrepared(); setState(playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); @@ -1036,7 +1023,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetRenderers= */ false, /* resetPosition= */ true, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); } else { // Execute the seek in the current media periods. @@ -1203,14 +1189,12 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - private void stopInternal( - boolean forceResetRenderers, boolean resetPositionAndState, boolean acknowledgeStop) { + private void stopInternal(boolean forceResetRenderers, boolean acknowledgeStop) { resetInternal( /* resetRenderers= */ forceResetRenderers || !foregroundMode, - /* resetPosition= */ resetPositionAndState, + /* resetPosition= */ false, /* releaseMediaSourceList= */ true, - /* clearMediaSourceList= */ resetPositionAndState, - /* resetError= */ resetPositionAndState); + /* resetError= */ false); playbackInfoUpdate.incrementPendingOperationAcks(acknowledgeStop ? 1 : 0); loadControl.onStopped(); setState(Player.STATE_IDLE); @@ -1219,9 +1203,8 @@ import java.util.concurrent.atomic.AtomicBoolean; private void releaseInternal() { resetInternal( /* resetRenderers= */ true, - /* resetPosition= */ true, + /* resetPosition= */ false, /* releaseMediaSourceList= */ true, - /* clearMediaSourceList= */ true, /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); @@ -1236,7 +1219,6 @@ import java.util.concurrent.atomic.AtomicBoolean; boolean resetRenderers, boolean resetPosition, boolean releaseMediaSourceList, - boolean clearMediaSourceList, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); rebuffering = false; @@ -1262,26 +1244,17 @@ import java.util.concurrent.atomic.AtomicBoolean; } enabledRendererCount = 0; - Timeline timeline = playbackInfo.timeline; - if (clearMediaSourceList) { - timeline = mediaSourceList.clear(/* shuffleOrder= */ null); - for (PendingMessageInfo pendingMessageInfo : pendingMessages) { - pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); - } - pendingMessages.clear(); - resetPosition = true; - } MediaPeriodId mediaPeriodId = playbackInfo.periodId; long startPositionUs = playbackInfo.positionUs; long requestedContentPositionUs = shouldUseRequestedContentPosition(playbackInfo, period, window) ? playbackInfo.requestedContentPositionUs : playbackInfo.positionUs; - boolean resetTrackInfo = clearMediaSourceList; + boolean resetTrackInfo = false; if (resetPosition) { pendingInitialSeekPosition = null; Pair firstPeriodAndPosition = - getPlaceholderFirstMediaPeriodPosition(timeline); + getPlaceholderFirstMediaPeriodPosition(playbackInfo.timeline); mediaPeriodId = firstPeriodAndPosition.first; startPositionUs = firstPeriodAndPosition.second; requestedContentPositionUs = C.TIME_UNSET; @@ -1295,7 +1268,7 @@ import java.util.concurrent.atomic.AtomicBoolean; playbackInfo = new PlaybackInfo( - timeline, + playbackInfo.timeline, mediaPeriodId, requestedContentPositionUs, playbackInfo.playbackState, @@ -1667,7 +1640,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetRenderers= */ false, /* resetPosition= */ false, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); } if (!periodPositionChanged) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 9a02c07242..6dd2753869 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -1193,19 +1193,21 @@ public interface Player { * player instance can still be used, and {@link #release()} must still be called on the player if * it's no longer required. * - *

        Calling this method does not reset the playback position. + *

        Calling this method does not clear the playlist, reset the playback position or the playback + * error. */ void stop(); /** - * Stops playback and optionally resets the player. Use {@link #pause()} rather than this method - * if the intention is to pause playback. + * Stops playback and optionally clears the playlist and resets the position and playback error. + * Use {@link #pause()} rather than this method if the intention is to pause playback. * *

        Calling this method will cause the playback state to transition to {@link #STATE_IDLE}. The * player instance can still be used, and {@link #release()} must still be called on the player if * it's no longer required. * - * @param reset Whether the player should be reset. + * @param reset Whether the playlist should be cleared and whether the playback position and + * playback error should be reset. */ void stop(boolean reset); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index c79a128f81..4632a0eaad 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -351,7 +351,7 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { } @Override - public void stop(boolean resetStateAndPosition) { + public void stop(boolean reset) { throw new UnsupportedOperationException(); } From a6640ae377c86547d0a590c1d17a945ddf25779a Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 16 Jul 2020 18:32:59 +0100 Subject: [PATCH 0701/1052] Stop merging methods through AnalyticsCollector/AnalyticsListener PiperOrigin-RevId: 321595514 --- .../android/exoplayer2/SimpleExoPlayer.java | 5 +- .../analytics/AnalyticsCollector.java | 18 +- .../analytics/AnalyticsListener.java | 168 +++++++----- .../audio/AudioRendererEventListener.java | 18 +- .../android/exoplayer2/util/EventLogger.java | 72 +++-- .../exoplayer2/video/VideoListener.java | 4 +- .../video/VideoRendererEventListener.java | 4 +- .../analytics/AnalyticsCollectorTest.java | 246 +++++++++++++++--- .../exoplayer2/testutil/ExoHostedTest.java | 15 +- 9 files changed, 402 insertions(+), 148 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 20abea0f2d..b7fb3a89ea 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -2224,10 +2224,9 @@ public class SimpleExoPlayer extends BasePlayer } @Override - public void onAudioSinkUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + public void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { - audioDebugListener.onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + audioDebugListener.onAudioUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index d7bda740b0..694f2bc8c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -172,34 +172,40 @@ public class AnalyticsCollector // AudioRendererEventListener implementation. + @SuppressWarnings("deprecation") @Override public final void onAudioEnabled(DecoderCounters counters) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioEnabled(eventTime, counters); listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_AUDIO, counters); } } + @SuppressWarnings("deprecation") @Override public final void onAudioDecoderInitialized( String decoderName, long initializedTimestampMs, long initializationDurationMs) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioDecoderInitialized(eventTime, decoderName, initializationDurationMs); listener.onDecoderInitialized( eventTime, C.TRACK_TYPE_AUDIO, decoderName, initializationDurationMs); } } + @SuppressWarnings("deprecation") @Override public final void onAudioInputFormatChanged(Format format) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioInputFormatChanged(eventTime, format); listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_AUDIO, format); } } @Override - public final void onAudioSinkUnderrun( + public final void onAudioUnderrun( int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { @@ -207,10 +213,12 @@ public class AnalyticsCollector } } + @SuppressWarnings("deprecation") @Override public final void onAudioDisabled(DecoderCounters counters) { EventTime eventTime = generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioDisabled(eventTime, counters); listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_AUDIO, counters); } } @@ -251,28 +259,34 @@ public class AnalyticsCollector // VideoRendererEventListener implementation. + @SuppressWarnings("deprecation") @Override public final void onVideoEnabled(DecoderCounters counters) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoEnabled(eventTime, counters); listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_VIDEO, counters); } } + @SuppressWarnings("deprecation") @Override public final void onVideoDecoderInitialized( String decoderName, long initializedTimestampMs, long initializationDurationMs) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoDecoderInitialized(eventTime, decoderName, initializationDurationMs); listener.onDecoderInitialized( eventTime, C.TRACK_TYPE_VIDEO, decoderName, initializationDurationMs); } } + @SuppressWarnings("deprecation") @Override public final void onVideoInputFormatChanged(Format format) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoInputFormatChanged(eventTime, format); listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_VIDEO, format); } } @@ -285,10 +299,12 @@ public class AnalyticsCollector } } + @SuppressWarnings("deprecation") @Override public final void onVideoDisabled(DecoderCounters counters) { EventTime eventTime = generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoDisabled(eventTime, counters); listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_VIDEO, counters); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java index 7115ebaf5b..1e2f53d8c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; import com.google.android.exoplayer2.Player.TimelineChangeReason; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -273,9 +272,8 @@ public interface AnalyticsListener { default void onSeekStarted(EventTime eventTime) {} /** - * @deprecated Seeks are processed without delay. Listen to {@link - * #onPositionDiscontinuity(EventTime, int)} with reason {@link - * Player#DISCONTINUITY_REASON_SEEK} instead. + * @deprecated Seeks are processed without delay. Use {@link #onPositionDiscontinuity(EventTime, + * int)} with reason {@link Player#DISCONTINUITY_REASON_SEEK} instead. */ @Deprecated default void onSeekProcessed(EventTime eventTime) {} @@ -442,17 +440,6 @@ public interface AnalyticsListener { default void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {} - /** - * Called when the output surface size changed. - * - * @param eventTime The event time. - * @param width The surface width in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if the - * video is not rendered onto a surface. - * @param height The surface height in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if - * the video is not rendered onto a surface. - */ - default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} - /** * Called when there is {@link Metadata} associated with the current playback time. * @@ -461,50 +448,78 @@ public interface AnalyticsListener { */ default void onMetadata(EventTime eventTime, Metadata metadata) {} - /** - * Called when an audio or video decoder has been enabled. - * - * @param eventTime The event time. - * @param trackType The track type of the enabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or - * {@link C#TRACK_TYPE_VIDEO}. - * @param decoderCounters The accumulated event counters associated with this decoder. - */ + /** @deprecated Use {@link #onAudioEnabled} and {@link #onVideoEnabled} instead. */ + @Deprecated default void onDecoderEnabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} /** - * Called when an audio or video decoder has been initialized. - * - * @param eventTime The event time. - * @param trackType The track type of the initialized decoder. Either {@link C#TRACK_TYPE_AUDIO} - * or {@link C#TRACK_TYPE_VIDEO}. - * @param decoderName The decoder that was created. - * @param initializationDurationMs Time taken to initialize the decoder, in milliseconds. + * @deprecated Use {@link #onAudioDecoderInitialized} and {@link #onVideoDecoderInitialized} + * instead. */ + @Deprecated default void onDecoderInitialized( EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {} /** - * Called when an audio or video decoder input format changed. - * - * @param eventTime The event time. - * @param trackType The track type of the decoder whose format changed. Either {@link - * C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. - * @param format The new input format for the decoder. + * @deprecated Use {@link #onAudioInputFormatChanged} and {@link #onVideoInputFormatChanged} + * instead. */ + @Deprecated default void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {} - /** - * Called when an audio or video decoder has been disabled. - * - * @param eventTime The event time. - * @param trackType The track type of the disabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or - * {@link C#TRACK_TYPE_VIDEO}. - * @param decoderCounters The accumulated event counters associated with this decoder. - */ + /** @deprecated Use {@link #onAudioDisabled} and {@link #onVideoDisabled} instead. */ + @Deprecated default void onDecoderDisabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + /** + * Called when an audio renderer is enabled. + * + * @param eventTime The event time. + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. + */ + default void onAudioEnabled(EventTime eventTime, DecoderCounters counters) {} + + /** + * Called when an audio renderer creates a decoder. + * + * @param eventTime The event time. + * @param decoderName The decoder that was created. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. + */ + default void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) {} + + /** + * Called when the format of the media being consumed by an audio renderer changes. + * + * @param eventTime The event time. + * @param format The new format. + */ + default void onAudioInputFormatChanged(EventTime eventTime, Format format) {} + + /** + * Called when an audio underrun occurs. + * + * @param eventTime The event time. + * @param bufferSize The size of the audio output buffer, in bytes. + * @param bufferSizeMs The size of the audio output buffer, in milliseconds, if it contains PCM + * encoded audio. {@link C#TIME_UNSET} if the output buffer contains non-PCM encoded audio. + * @param elapsedSinceLastFeedMs The time since audio was last written to the output buffer. + */ + default void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + + /** + * Called when an audio renderer is disabled. + * + * @param eventTime The event time. + * @param counters {@link DecoderCounters} that were updated by the renderer. + */ + default void onAudioDisabled(EventTime eventTime, DecoderCounters counters) {} + /** * Called when the audio session id is set. * @@ -521,6 +536,14 @@ public interface AnalyticsListener { */ default void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audioAttributes) {} + /** + * Called when skipping silences is enabled or disabled in the audio stream. + * + * @param eventTime The event time. + * @param skipSilenceEnabled Whether skipping silences in the audio stream is enabled. + */ + default void onSkipSilenceEnabledChanged(EventTime eventTime, boolean skipSilenceEnabled) {} + /** * Called when the volume changes. * @@ -530,25 +553,31 @@ public interface AnalyticsListener { default void onVolumeChanged(EventTime eventTime, float volume) {} /** - * Called when an audio underrun occurred. + * Called when a video renderer is enabled. * * @param eventTime The event time. - * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. - * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is - * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, - * as the buffered media can have a variable bitrate so the duration may be unknown. - * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. */ - default void onAudioUnderrun( - EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + default void onVideoEnabled(EventTime eventTime, DecoderCounters counters) {} /** - * Called when skipping silences is enabled or disabled in the audio stream. + * Called when a video renderer creates a decoder. * * @param eventTime The event time. - * @param skipSilenceEnabled Whether skipping silences in the audio stream is enabled. + * @param decoderName The decoder that was created. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. */ - default void onSkipSilenceEnabledChanged(EventTime eventTime, boolean skipSilenceEnabled) {} + default void onVideoDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) {} + + /** + * Called when the format of the media being consumed by a video renderer changes. + * + * @param eventTime The event time. + * @param format The new format. + */ + default void onVideoInputFormatChanged(EventTime eventTime, Format format) {} /** * Called after video frames have been dropped. @@ -561,6 +590,14 @@ public interface AnalyticsListener { */ default void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {} + /** + * Called when a video renderer is disabled. + * + * @param eventTime The event time. + * @param counters {@link DecoderCounters} that were updated by the renderer. + */ + default void onVideoDisabled(EventTime eventTime, DecoderCounters counters) {} + /** * Called when there is an update to the video frame processing offset reported by a video * renderer. @@ -580,6 +617,16 @@ public interface AnalyticsListener { default void onVideoFrameProcessingOffset( EventTime eventTime, long totalProcessingOffsetUs, int frameCount, Format format) {} + /** + * Called when a frame is rendered for the first time since setting the surface, or since the + * renderer was reset, or since the stream being rendered was changed. + * + * @param eventTime The event time. + * @param surface The {@link Surface} to which a frame has been rendered, or {@code null} if the + * renderer renders to something that isn't a {@link Surface}. + */ + default void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) {} + /** * Called before a frame is rendered for the first time since setting the surface, and each time * there's a change in the size or pixel aspect ratio of the video being rendered. @@ -601,14 +648,15 @@ public interface AnalyticsListener { float pixelWidthHeightRatio) {} /** - * Called when a frame is rendered for the first time since setting the surface, and when a frame - * is rendered for the first time since the renderer was reset. + * Called when the output surface size changed. * * @param eventTime The event time. - * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if - * the renderer renders to something that isn't a {@link Surface}. + * @param width The surface width in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if the + * video is not rendered onto a surface. + * @param height The surface height in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if + * the video is not rendered onto a surface. */ - default void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) {} + default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} /** * Called each time a drm session is acquired. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java index 104467c91e..c366f27f81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -66,16 +66,14 @@ public interface AudioRendererEventListener { default void onAudioInputFormatChanged(Format format) {} /** - * Called when an {@link AudioSink} underrun occurs. + * Called when an audio underrun occurs. * - * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. - * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is - * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, - * as the buffered media can have a variable bitrate so the duration may be unknown. - * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + * @param bufferSize The size of the audio output buffer, in bytes. + * @param bufferSizeMs The size of the audio output buffer, in milliseconds, if it contains PCM + * encoded audio. {@link C#TIME_UNSET} if the output buffer contains non-PCM encoded audio. + * @param elapsedSinceLastFeedMs The time since audio was last written to the output buffer. */ - default void onAudioSinkUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + default void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} /** * Called when the renderer is disabled. @@ -140,14 +138,14 @@ public interface AudioRendererEventListener { } } - /** Invokes {@link AudioRendererEventListener#onAudioSinkUnderrun(int, long, long)}. */ + /** Invokes {@link AudioRendererEventListener#onAudioUnderrun(int, long, long)}. */ public void underrun( final int bufferSize, final long bufferSizeMs, final long elapsedSinceLastFeedMs) { if (handler != null) { handler.post( () -> castNonNull(listener) - .onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)); + .onAudioUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index 27b95515c1..207b247af5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -301,8 +301,34 @@ public class EventLogger implements AnalyticsListener { } @Override - public void onDecoderEnabled(EventTime eventTime, int trackType, DecoderCounters counters) { - logd(eventTime, "decoderEnabled", Util.getTrackTypeString(trackType)); + public void onAudioEnabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "audioEnabled"); + } + + @Override + public void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + logd(eventTime, "audioDecoderInitialized", decoderName); + } + + @Override + public void onAudioInputFormatChanged(EventTime eventTime, Format format) { + logd(eventTime, "audioInputFormat", Format.toLogString(format)); + } + + @Override + public void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + loge( + eventTime, + "audioTrackUnderrun", + bufferSize + ", " + bufferSizeMs + ", " + elapsedSinceLastFeedMs + "]", + /* throwable= */ null); + } + + @Override + public void onAudioDisabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "audioDisabled"); } @Override @@ -335,32 +361,19 @@ public class EventLogger implements AnalyticsListener { } @Override - public void onDecoderInitialized( - EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) { - logd(eventTime, "decoderInitialized", Util.getTrackTypeString(trackType) + ", " + decoderName); + public void onVideoEnabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "videoEnabled"); } @Override - public void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) { - logd( - eventTime, - "decoderInputFormat", - Util.getTrackTypeString(trackType) + ", " + Format.toLogString(format)); + public void onVideoDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + logd(eventTime, "videoDecoderInitialized", decoderName); } @Override - public void onDecoderDisabled(EventTime eventTime, int trackType, DecoderCounters counters) { - logd(eventTime, "decoderDisabled", Util.getTrackTypeString(trackType)); - } - - @Override - public void onAudioUnderrun( - EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - loge( - eventTime, - "audioTrackUnderrun", - bufferSize + ", " + bufferSizeMs + ", " + elapsedSinceLastFeedMs + "]", - null); + public void onVideoInputFormatChanged(EventTime eventTime, Format format) { + logd(eventTime, "videoInputFormat", Format.toLogString(format)); } @Override @@ -368,6 +381,16 @@ public class EventLogger implements AnalyticsListener { logd(eventTime, "droppedFrames", Integer.toString(count)); } + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "videoDisabled"); + } + + @Override + public void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) { + logd(eventTime, "renderedFirstFrame", String.valueOf(surface)); + } + @Override public void onVideoSizeChanged( EventTime eventTime, @@ -378,11 +401,6 @@ public class EventLogger implements AnalyticsListener { logd(eventTime, "videoSize", width + ", " + height); } - @Override - public void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) { - logd(eventTime, "renderedFirstFrame", String.valueOf(surface)); - } - @Override public void onMediaPeriodCreated(EventTime eventTime) { logd(eventTime, "mediaPeriodCreated"); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java index 948c388c30..589371cde5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java @@ -51,8 +51,8 @@ public interface VideoListener { default void onSurfaceSizeChanged(int width, int height) {} /** - * Called when a frame is rendered for the first time since setting the surface, and when a frame - * is rendered for the first time since the renderer was reset. + * Called when a frame is rendered for the first time since setting the surface, or since the + * renderer was reset, or since the stream being rendered was changed. */ default void onRenderedFirstFrame() {} } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java index f8037d2e72..5501c1c576 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java @@ -114,8 +114,8 @@ public interface VideoRendererEventListener { int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {} /** - * Called when a frame is rendered for the first time since setting the surface, and when a frame - * is rendered for the first time since the renderer was reset. + * Called when a frame is rendered for the first time since setting the surface, or since the + * renderer was reset, or since the stream being rendered was changed. * * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if * the renderer renders to something that isn't a {@link Surface}. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index c94e39dd68..03503defc8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -107,18 +107,26 @@ public final class AnalyticsCollectorTest { private static final int EVENT_DECODER_INIT = 25; private static final int EVENT_DECODER_FORMAT_CHANGED = 26; private static final int EVENT_DECODER_DISABLED = 27; - private static final int EVENT_AUDIO_SESSION_ID = 28; - private static final int EVENT_AUDIO_UNDERRUN = 29; - private static final int EVENT_DROPPED_VIDEO_FRAMES = 30; - private static final int EVENT_VIDEO_SIZE_CHANGED = 31; - private static final int EVENT_RENDERED_FIRST_FRAME = 32; - private static final int EVENT_DRM_KEYS_LOADED = 33; - private static final int EVENT_DRM_ERROR = 34; - private static final int EVENT_DRM_KEYS_RESTORED = 35; - private static final int EVENT_DRM_KEYS_REMOVED = 36; - private static final int EVENT_DRM_SESSION_ACQUIRED = 37; - private static final int EVENT_DRM_SESSION_RELEASED = 38; - private static final int EVENT_VIDEO_FRAME_PROCESSING_OFFSET = 39; + private static final int EVENT_AUDIO_ENABLED = 28; + private static final int EVENT_AUDIO_DECODER_INIT = 29; + private static final int EVENT_AUDIO_INPUT_FORMAT_CHANGED = 30; + private static final int EVENT_AUDIO_DISABLED = 31; + private static final int EVENT_AUDIO_SESSION_ID = 32; + private static final int EVENT_AUDIO_UNDERRUN = 33; + private static final int EVENT_VIDEO_ENABLED = 34; + private static final int EVENT_VIDEO_DECODER_INIT = 35; + private static final int EVENT_VIDEO_INPUT_FORMAT_CHANGED = 36; + private static final int EVENT_DROPPED_FRAMES = 37; + private static final int EVENT_VIDEO_DISABLED = 38; + private static final int EVENT_RENDERED_FIRST_FRAME = 39; + private static final int EVENT_VIDEO_FRAME_PROCESSING_OFFSET = 40; + private static final int EVENT_VIDEO_SIZE_CHANGED = 41; + private static final int EVENT_DRM_KEYS_LOADED = 42; + private static final int EVENT_DRM_ERROR = 43; + private static final int EVENT_DRM_KEYS_RESTORED = 44; + private static final int EVENT_DRM_KEYS_REMOVED = 45; + private static final int EVENT_DRM_SESSION_ACQUIRED = 46; + private static final int EVENT_DRM_SESSION_RELEASED = 47; private static final UUID DRM_SCHEME_UUID = UUID.nameUUIDFromBytes(TestUtil.createByteArray(7, 8, 9)); @@ -222,8 +230,14 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly(period0 /* audio */, period0 /* video */) .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period0); @@ -298,8 +312,22 @@ public final class AnalyticsCollectorTest { .containsExactly( period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(period0, period1) .inOrder(); @@ -369,9 +397,16 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly(period0 /* video */, period1 /* audio */) .inOrder(); - assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0 /* video */); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)).containsExactly(period1); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period1); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period0); @@ -460,9 +495,21 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_DECODER_DISABLED)) .containsExactly(period0 /* video */, period0 /* audio */) .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period0, period1).inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)) .containsExactly(period0, period1) .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); listener.assertNoMoreEvents(); @@ -555,10 +602,28 @@ public final class AnalyticsCollectorTest { .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0, period0); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)) + .containsExactly(period1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)) + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)) + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)) .containsExactly(period1Seq1, period1Seq2) .inOrder(); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) + assertThat(listener.getEvents(EVENT_AUDIO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0, period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0, period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)) .containsExactly(period0, period1Seq2) .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) @@ -658,7 +723,17 @@ public final class AnalyticsCollectorTest { .containsExactly(period0Seq0, period0Seq1) .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq1); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(period0Seq0, period0Seq1) .inOrder(); @@ -736,7 +811,13 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0Seq0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) @@ -822,7 +903,17 @@ public final class AnalyticsCollectorTest { .containsExactly(window0Period1Seq0, window1Period0Seq1) .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(window0Period1Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(window0Period1Seq0, window0Period1Seq0) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(window0Period1Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)) .containsExactly(window0Period1Seq0, period1Seq0) .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) @@ -917,7 +1008,17 @@ public final class AnalyticsCollectorTest { .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)) .containsExactly(period0Seq0, period0Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq1); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(period0Seq0, period0Seq1, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(period0Seq0, period0Seq1) .inOrder(); @@ -1177,7 +1278,26 @@ public final class AnalyticsCollectorTest { postrollAd, contentAfterPostroll) .inOrder(); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(prerollAd); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly( + prerollAd, + contentAfterPreroll, + midrollAd, + contentAfterMidroll, + postrollAd, + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly( + prerollAd, + contentAfterPreroll, + midrollAd, + contentAfterMidroll, + postrollAd, + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)) .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) @@ -1344,7 +1464,17 @@ public final class AnalyticsCollectorTest { .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(contentBeforeMidroll); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(contentAfterMidroll); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(contentBeforeMidroll, midrollAd) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(contentBeforeMidroll); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(contentAfterMidroll); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) .inOrder(); @@ -1847,29 +1977,54 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_METADATA, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderEnabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_ENABLED, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderInitialized( EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_INIT, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_FORMAT_CHANGED, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderDisabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_DISABLED, eventTime)); } + @Override + public void onAudioEnabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_ENABLED, eventTime)); + } + + @Override + public void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_DECODER_INIT, eventTime)); + } + + @Override + public void onAudioInputFormatChanged(EventTime eventTime, Format format) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_INPUT_FORMAT_CHANGED, eventTime)); + } + + @Override + public void onAudioDisabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_DISABLED, eventTime)); + } + @Override public void onAudioSessionId(EventTime eventTime, int audioSessionId) { reportedEvents.add(new ReportedEvent(EVENT_AUDIO_SESSION_ID, eventTime)); @@ -1881,9 +2036,41 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_AUDIO_UNDERRUN, eventTime)); } + @Override + public void onVideoEnabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_ENABLED, eventTime)); + } + + @Override + public void onVideoDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_DECODER_INIT, eventTime)); + } + + @Override + public void onVideoInputFormatChanged(EventTime eventTime, Format format) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_INPUT_FORMAT_CHANGED, eventTime)); + } + @Override public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { - reportedEvents.add(new ReportedEvent(EVENT_DROPPED_VIDEO_FRAMES, eventTime)); + reportedEvents.add(new ReportedEvent(EVENT_DROPPED_FRAMES, eventTime)); + } + + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_DISABLED, eventTime)); + } + + @Override + public void onVideoFrameProcessingOffset( + EventTime eventTime, long totalProcessingOffsetUs, int frameCount, Format format) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_FRAME_PROCESSING_OFFSET, eventTime)); + } + + @Override + public void onRenderedFirstFrame(EventTime eventTime, Surface surface) { + reportedEvents.add(new ReportedEvent(EVENT_RENDERED_FIRST_FRAME, eventTime)); } @Override @@ -1896,11 +2083,6 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_VIDEO_SIZE_CHANGED, eventTime)); } - @Override - public void onRenderedFirstFrame(EventTime eventTime, Surface surface) { - reportedEvents.add(new ReportedEvent(EVENT_RENDERED_FIRST_FRAME, eventTime)); - } - @Override public void onDrmSessionAcquired(EventTime eventTime) { reportedEvents.add(new ReportedEvent(EVENT_DRM_SESSION_ACQUIRED, eventTime)); @@ -1931,12 +2113,6 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_DRM_SESSION_RELEASED, eventTime)); } - @Override - public void onVideoFrameProcessingOffset( - EventTime eventTime, long totalProcessingOffsetUs, int frameCount, Format format) { - reportedEvents.add(new ReportedEvent(EVENT_VIDEO_FRAME_PROCESSING_OFFSET, eventTime)); - } - private static final class ReportedEvent { public final int eventType; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index eba309abdf..7444d35e8e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -22,7 +22,6 @@ import android.os.SystemClock; import android.view.Surface; import android.widget.FrameLayout; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; @@ -209,13 +208,13 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { } @Override - public void onDecoderDisabled( - EventTime eventTime, int trackType, DecoderCounters decoderCounters) { - if (trackType == C.TRACK_TYPE_AUDIO) { - audioDecoderCounters.merge(decoderCounters); - } else if (trackType == C.TRACK_TYPE_VIDEO) { - videoDecoderCounters.merge(decoderCounters); - } + public void onAudioDisabled(EventTime eventTime, DecoderCounters decoderCounters) { + audioDecoderCounters.merge(decoderCounters); + } + + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) { + videoDecoderCounters.merge(decoderCounters); } // Internal logic From bcf218da6045e571c56972f320ddee88029ea4b7 Mon Sep 17 00:00:00 2001 From: insun Date: Fri, 17 Jul 2020 13:43:38 +0100 Subject: [PATCH 0702/1052] Import translated strings PiperOrigin-RevId: 321762840 --- library/ui/src/main/res/values-af/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-am/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ar/strings.xml | 20 +++++++++++++++---- library/ui/src/main/res/values-az/strings.xml | 14 ++++++++++++- .../src/main/res/values-b+sr+Latn/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-be/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-bg/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-bn/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-bs/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ca/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-cs/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-da/strings.xml | 16 +++++++++++++-- library/ui/src/main/res/values-de/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-el/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-en-rAU/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-en-rGB/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-en-rIN/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-es-rUS/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-es/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-et/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-eu/strings.xml | 16 +++++++++++++-- library/ui/src/main/res/values-fa/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-fi/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-fr-rCA/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-fr/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-gl/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-gu/strings.xml | 18 ++++++++++++++--- library/ui/src/main/res/values-hi/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-hr/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-hu/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-hy/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-in/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-is/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-it/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-iw/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ja/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ka/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-kk/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-km/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-kn/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ko/strings.xml | 16 +++++++++++++-- library/ui/src/main/res/values-ky/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-lo/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-lt/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-lv/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-mk/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ml/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-mn/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-mr/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ms/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-my/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-nb/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ne/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-nl/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-pa/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-pl/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-pt-rPT/strings.xml | 16 +++++++++++++-- library/ui/src/main/res/values-pt/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ro/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ru/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-si/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-sk/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-sl/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-sq/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-sr/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-sv/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-sw/strings.xml | 20 +++++++++++++++---- library/ui/src/main/res/values-ta/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-te/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-th/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-tl/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-tr/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-uk/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-ur/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-uz/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-vi/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-zh-rCN/strings.xml | 18 ++++++++++++++--- .../ui/src/main/res/values-zh-rHK/strings.xml | 14 ++++++++++++- .../ui/src/main/res/values-zh-rTW/strings.xml | 14 ++++++++++++- library/ui/src/main/res/values-zu/strings.xml | 14 ++++++++++++- 80 files changed, 1054 insertions(+), 94 deletions(-) diff --git a/library/ui/src/main/res/values-af/strings.xml b/library/ui/src/main/res/values-af/strings.xml index e990737892..9916db924e 100644 --- a/library/ui/src/main/res/values-af/strings.xml +++ b/library/ui/src/main/res/values-af/strings.xml @@ -28,7 +28,6 @@ Herhaal alles Skommel is aan Skommel is af - Volskermmodus VR-modus Aflaai Aflaaie @@ -54,4 +53,17 @@ Onderskrifte %1$.2f Mbps %1$s, %2$s + Oudiosnit + Terugspeelspoed + Normaal + \"Terug na vorige\"-knoppielys + Sien nog knoppies + Terugspeelvordering + Instellings + Tik om onderskrifte te versteek + Tik om onderskrifte te wys + Gaan %d sekondes terug + Gaan %d sekondes vinnig vorentoe + Gaan na volskerm + Verlaat volskerm diff --git a/library/ui/src/main/res/values-am/strings.xml b/library/ui/src/main/res/values-am/strings.xml index ff891b149f..57c495ae69 100644 --- a/library/ui/src/main/res/values-am/strings.xml +++ b/library/ui/src/main/res/values-am/strings.xml @@ -28,7 +28,6 @@ ሁሉንም ድገም መበወዝ በርቷል መበወዝ ጠፍቷል - የሙሉ ማያ ሁነታ የቪአር ሁነታ አውርድ የወረዱ @@ -54,4 +53,17 @@ የተዘጉ የመግለጫ ጽሑፎች %1$.2f ሜብስ %1$s፣ %2$s + ኦዲዮ ትራክ + የመልሶ ማጫወት ፍጥነት + መደበኛ + ወደ ቀዳሚው የአዝራር ዝርዝር ተመለስ + ተጨማሪ አዝራሮችን ይመልከቱ + የመልሶ ማጫወት ሂደት + ቅንብሮች + የግርጌ ጽሑፎችን ለመደበቅ መታ ያድርጉ + የግርጌ ጽሑፎችን ለማሳየት መታ ያድርጉ + በ%d ሰከንዶች ወደኋላ መልስ + በ%d ሰከንዶች ወደፊት አሳልፍ + ወደ ሙሉ ማያ ገጽ ግባ + ከሙሉ ማያገጽ ውጣ diff --git a/library/ui/src/main/res/values-ar/strings.xml b/library/ui/src/main/res/values-ar/strings.xml index 156e17d8a9..abc68fa768 100644 --- a/library/ui/src/main/res/values-ar/strings.xml +++ b/library/ui/src/main/res/values-ar/strings.xml @@ -21,21 +21,20 @@ إيقاف مؤقت تشغيل إيقاف - إرجاع + ترجيع تقديم سريع عدم التكرار تكرار مقطع صوتي واحد تكرار الكل تفعيل الترتيب العشوائي إيقاف الترتيب العشوائي - وضع ملء الشاشة وضع VR تنزيل - التنزيلات + عمليات التنزيل جارٍ التنزيل. اكتمل التنزيل تعذّر التنزيل - جارٍ إزالة التنزيلات + تجري إزالة المحتوى الذي تم تنزيله فيديو صوت نص @@ -54,4 +53,17 @@ الترجمة والشرح %1$.2f ميغابت في الثانية %1$s، %2$s + المقطع الصوتي + سرعة التشغيل + عادية + الرجوع إلى قائمة الازرار السابقة + عرض مزيد من الأزرار + مستوى تقدُّم التشغيل + الإعدادات + النقر لإخفاء الترجمة + النقر لإظهار الترجمة + ترجيع الفيديو بمقدار %d ثانية + تقديم سريع للفيديو بمقدار %d ثانية + دخول إلى وضع ملء الشاشة + خروج من وضع ملء الشاشة diff --git a/library/ui/src/main/res/values-az/strings.xml b/library/ui/src/main/res/values-az/strings.xml index 776b5faf15..f56482f2dd 100644 --- a/library/ui/src/main/res/values-az/strings.xml +++ b/library/ui/src/main/res/values-az/strings.xml @@ -28,7 +28,6 @@ Hamısı təkrarlansın Qarışdırma aktivdir Qarışdırma deaktivdir - Tam ekran rejimi VR rejimi Endirin Endirmələr @@ -54,4 +53,17 @@ Nüsxəni alan %1$.2f Mbps %1$s, %2$s + Audio trek + Oxutma sürəti + Normal + Əvvəlki düymə siyahısına geri + Daha çox düyməyə baxın + Oxutmanın gedişatı + Ayarlar + Altyazıları gizlətmək üçün toxunun + Altyazıları göstərmək üçün toxunun + %d saniyə geri keçin + %d saniyə irəli keçin + Tam ekrana daxil olun + Tam ekrandan çıxın diff --git a/library/ui/src/main/res/values-b+sr+Latn/strings.xml b/library/ui/src/main/res/values-b+sr+Latn/strings.xml index beb1dd96c1..98aa6faee6 100644 --- a/library/ui/src/main/res/values-b+sr+Latn/strings.xml +++ b/library/ui/src/main/res/values-b+sr+Latn/strings.xml @@ -28,7 +28,6 @@ Ponovi sve Nasumično puštanje je uključeno Nasumično puštanje je isključeno - Režim celog ekrana VR režim Preuzmi Preuzimanja @@ -54,4 +53,17 @@ Titl %1$.2f Mb/s %1$s, %2$s + Audio snimak + Brzina reprodukcije + Uobičajena + Nazad na prethodnu listu dugmadi + Prikaži još dugmadi + Napredovanje reprodukcije + Podešavanja + Dodirnite da bi se titlovi sakrili + Dodirnite da bi se titlovi prikazivali + Premotaj %d sekundi unazad + Premotaj %d sekundi unapred + Pređi na ceo ekran + Izađi iz celog ekrana diff --git a/library/ui/src/main/res/values-be/strings.xml b/library/ui/src/main/res/values-be/strings.xml index 95d4c605a5..7f9bb857f0 100644 --- a/library/ui/src/main/res/values-be/strings.xml +++ b/library/ui/src/main/res/values-be/strings.xml @@ -28,7 +28,6 @@ Паўтарыць усе Перамешванне ўключана Перамешванне выключана - Поўнаэкранны рэжым VR-рэжым Спампаваць Спампоўкі @@ -54,4 +53,17 @@ Цітры %1$.2f Мбіт/с %1$s, %2$s + Гукавая дарожка + Хуткасць прайгравання + Звычайная + Да папярэдняга спіса кнопак + Паказаць дадатковыя кнопкі + Ход прайгравання + Налады + Націсніце, каб схаваць субцітры + Націсніце, каб паказаць субцітры + Пераматаць назад на %d с + Пераматаць уперад на %d с + Уключыць поўнаэкранны рэжым + Выключыць поўнаэкранны рэжым diff --git a/library/ui/src/main/res/values-bg/strings.xml b/library/ui/src/main/res/values-bg/strings.xml index 39bf64d674..e42cf75d2f 100644 --- a/library/ui/src/main/res/values-bg/strings.xml +++ b/library/ui/src/main/res/values-bg/strings.xml @@ -28,7 +28,6 @@ Повтаряне на всички Разбъркването е включено Разбъркването е изключено - Режим на цял екран режим за VR Изтегляне Изтегляния @@ -54,4 +53,17 @@ Субтитри %1$.2f Мб/сек %1$s – %2$s + Аудиозапис + Скорост на възпроизвеждане + Нормално + Към предишния списък с бутони + Вижте още бутони + Напредък на възпроизвеждането + Настройки + Докоснете за скриване на субтитрите + Докоснете за показване на субтитрите + Превъртане с(ъс) %d секунди назад + Превъртане с(ъс) %d секунди напред + Вход в цял екран + Изход от цял екран diff --git a/library/ui/src/main/res/values-bn/strings.xml b/library/ui/src/main/res/values-bn/strings.xml index 4f7a94b1b3..39224c4be9 100644 --- a/library/ui/src/main/res/values-bn/strings.xml +++ b/library/ui/src/main/res/values-bn/strings.xml @@ -28,7 +28,6 @@ সবগুলি আইটেম আবার চালান শাফেল মোড চালু করা হয়েছে শাফেল মোড বন্ধ করা হয়েছে - পূর্ণ স্ক্রিন মোড ভিআর মোড ডাউনলোড করুন ডাউনলোড @@ -54,4 +53,17 @@ CC %1$.2f এমবিপিএস %1$s, %2$s + অডিও ট্র্যাক + প্লেব্যাক স্পিড + সাধারণ + আগের বোতামের তালিকাতে ফিরে যান + আরও বোতাম দেখুন + কতটা প্লেব্যাক হয়েছে + সেটিংস + সাবটাইটেল লুকাতে ট্যাপ করুন + সাবটাইটেল দেখাতে ট্যাপ করুন + %d সেকেন্ড রিওয়াইন্ড করুন + %d সেকেন্ড ফাস্ট ফরওয়ার্ড করুন + ফুল-স্ক্রিন মোডে দেখুন + ফুল-স্ক্রিন মোড ছেড়ে বেরোন diff --git a/library/ui/src/main/res/values-bs/strings.xml b/library/ui/src/main/res/values-bs/strings.xml index 090a801221..4079e7d448 100644 --- a/library/ui/src/main/res/values-bs/strings.xml +++ b/library/ui/src/main/res/values-bs/strings.xml @@ -28,7 +28,6 @@ Ponovi sve Uključi nasumično Isključi nasumično - Način rada preko cijelog ekrana VR način rada Preuzmi Preuzimanja @@ -54,4 +53,17 @@ Titlovi %1$.2f Mbps %1$s, %2$s + Zvučni zapis + Brzina reprodukcije + Normalno + Nazad na prethodnu listu dugmadi + Prikaz više dugmadi + Napredak reprodukcije + Postavke + Dodirnite da sakrijete titlove + Dodirnite da prikažete titlove + Premotavanje %d s nazad + Premotavanje %d s naprijed + Prikaz preko cijelog ekrana + Isključ. prikaza preko cijelog ekrana diff --git a/library/ui/src/main/res/values-ca/strings.xml b/library/ui/src/main/res/values-ca/strings.xml index 3dbc6367ed..1d00e8f720 100644 --- a/library/ui/src/main/res/values-ca/strings.xml +++ b/library/ui/src/main/res/values-ca/strings.xml @@ -28,7 +28,6 @@ Repeteix tot Activa reproducció aleatòria Desactiva reproducció aleatòria - Mode de pantalla completa Mode RV Baixa Baixades @@ -54,4 +53,17 @@ Subtítols %1$.2f Mbps %1$s, %2$s + Pista d\'àudio + Velocitat de reproducció + Normal + Torna a llista de botons anterior + Mostra més botons + Progrés de la reproducció + Configuració + Toca per amagar els subtítols + Toca per mostrar els subtítols + Rebobina %d segons + Avança ràpidament %d segons + Entra a la pantalla completa + Surt de la pantalla completa diff --git a/library/ui/src/main/res/values-cs/strings.xml b/library/ui/src/main/res/values-cs/strings.xml index 649bfebc0a..77a7c46cbe 100644 --- a/library/ui/src/main/res/values-cs/strings.xml +++ b/library/ui/src/main/res/values-cs/strings.xml @@ -28,7 +28,6 @@ Opakovat vše Náhodné přehrávání zapnuto Náhodné přehrávání vypnuto - Režim celé obrazovky Režim VR Stáhnout Stahování @@ -54,4 +53,17 @@ Titulky %1$.2f Mb/s %1$s, %2$s + Zvuková stopa + Rychlost přehrávání + Normální + Zpět na předchozí seznam tlačítek + Zobrazit další tlačítka + Průběh přehrávání + Nastavení + Klepnutím skryjete titulky + Klepnutím zobrazíte titulky + Přetočit zpět o %d s + Posunout vpřed o %d s + Přejít do režimu celé obrazovky + Ukončit režim celé obrazovky diff --git a/library/ui/src/main/res/values-da/strings.xml b/library/ui/src/main/res/values-da/strings.xml index cc6b5f2247..7ed8ca39b1 100644 --- a/library/ui/src/main/res/values-da/strings.xml +++ b/library/ui/src/main/res/values-da/strings.xml @@ -16,7 +16,7 @@ Vis afspilningsknapper Skjul afspilningsknapper - Afspil forrige + Forrige nummer Afspil næste Sæt på pause Afspil @@ -28,7 +28,6 @@ Gentag alle Bland er slået til Bland er slået fra - Fuld skærm VR-tilstand Download Downloads @@ -54,4 +53,17 @@ Undertekster %1$.2f Mbps %1$s, %2$s + Lydspor + Afspilningshastighed + Normal + Tilbage til forrige liste over knapper + Se flere knapper + Afspilningsstatus + Indstillinger + Tryk for at skjule undertekster + Tryk for at vise undertekster + Spol %d sekunder tilbage + Spol %d sekunder frem + Åbn fuld skærm + Afslut fuld skærm diff --git a/library/ui/src/main/res/values-de/strings.xml b/library/ui/src/main/res/values-de/strings.xml index c59b54dc6d..e8a6ed690c 100644 --- a/library/ui/src/main/res/values-de/strings.xml +++ b/library/ui/src/main/res/values-de/strings.xml @@ -28,7 +28,6 @@ Alle wiederholen Zufallsmix an Zufallsmix aus - Vollbildmodus VR-Modus Herunterladen Downloads @@ -54,4 +53,17 @@ Untertitel %1$.2f Mbit/s %1$s, %2$s + Audiotitel + Wiedergabegeschwindigkeit + Normal + Zurück zur vorherigen Schaltflächenliste + Weitere Schaltflächen zeigen + Wiedergabefortschritt + Einstellungen + Zum Ausblenden von Untertiteln tippen + Zum Einblenden von Untertiteln tippen + %d Sekunden zurückspulen + %d Sekunden vorspulen + Vollbild aktivieren + Vollbild beenden diff --git a/library/ui/src/main/res/values-el/strings.xml b/library/ui/src/main/res/values-el/strings.xml index f4728b6b19..68cb9fad8b 100644 --- a/library/ui/src/main/res/values-el/strings.xml +++ b/library/ui/src/main/res/values-el/strings.xml @@ -28,7 +28,6 @@ Επανάληψη όλων Τυχαία αναπαραγωγή: Ενεργή Τυχαία αναπαραγωγή: Ανενεργή - Λειτουργία πλήρους οθόνης Λειτουργία VR mode Λήψη Λήψεις @@ -54,4 +53,17 @@ Υπότιτλοι %1$.2f Mbps %1$s, %2$s + Κομμάτι ήχου + Ταχύτητα αναπαραγωγής + Κανονική + Επιστρ. σε προηγ. λίστα κουμπιών + Εμφάνιση περισσότερων κουμπιών + Πρόοδος αναπαραγωγής + Ρυθμίσεις + Πατήστε για απόκρυψη υποτίτλων + Πατήστε για εμφάνιση υποτίτλων + Μετάβαση προς τα πίσω κατά %d δευτερόλεπτα + Γρήγορη προώθηση κατά %d δευτερόλεπτα + Πλήρης οθόνη + Έξοδος από πλήρη οθόνη diff --git a/library/ui/src/main/res/values-en-rAU/strings.xml b/library/ui/src/main/res/values-en-rAU/strings.xml index a56d917fd4..ce890fb933 100644 --- a/library/ui/src/main/res/values-en-rAU/strings.xml +++ b/library/ui/src/main/res/values-en-rAU/strings.xml @@ -28,7 +28,6 @@ Repeat all Shuffle on Shuffle off - Full-screen mode VR mode Download Downloads @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Audio track + Playback speed + Normal + Back to previous button list + See more buttons + Playback progress + Settings + Tap to hide subtitles + Tap to show subtitles + Rewind %d seconds + Fast forward %d seconds + Enter fullscreen + Exit fullscreen diff --git a/library/ui/src/main/res/values-en-rGB/strings.xml b/library/ui/src/main/res/values-en-rGB/strings.xml index a56d917fd4..ce890fb933 100644 --- a/library/ui/src/main/res/values-en-rGB/strings.xml +++ b/library/ui/src/main/res/values-en-rGB/strings.xml @@ -28,7 +28,6 @@ Repeat all Shuffle on Shuffle off - Full-screen mode VR mode Download Downloads @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Audio track + Playback speed + Normal + Back to previous button list + See more buttons + Playback progress + Settings + Tap to hide subtitles + Tap to show subtitles + Rewind %d seconds + Fast forward %d seconds + Enter fullscreen + Exit fullscreen diff --git a/library/ui/src/main/res/values-en-rIN/strings.xml b/library/ui/src/main/res/values-en-rIN/strings.xml index a56d917fd4..ce890fb933 100644 --- a/library/ui/src/main/res/values-en-rIN/strings.xml +++ b/library/ui/src/main/res/values-en-rIN/strings.xml @@ -28,7 +28,6 @@ Repeat all Shuffle on Shuffle off - Full-screen mode VR mode Download Downloads @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Audio track + Playback speed + Normal + Back to previous button list + See more buttons + Playback progress + Settings + Tap to hide subtitles + Tap to show subtitles + Rewind %d seconds + Fast forward %d seconds + Enter fullscreen + Exit fullscreen diff --git a/library/ui/src/main/res/values-es-rUS/strings.xml b/library/ui/src/main/res/values-es-rUS/strings.xml index a0e4a53283..bb34bbe0e5 100644 --- a/library/ui/src/main/res/values-es-rUS/strings.xml +++ b/library/ui/src/main/res/values-es-rUS/strings.xml @@ -28,7 +28,6 @@ Repetir todo Reprod. aleatoria activada Reprod. aleatoria desactivada - Modo de pantalla completa Modo RV Descargar Descargas @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Pista de audio + Velocidad de reproducción + Normal + Volver a la lista anterior + Ver más botones + Reproducción en curso + Configuración + Presiona para ocultar los subtítulos + Presiona para mostrar los subtítulos + Retroceder %d segundos + Avanzar %d segundos + Iniciar pantalla completa + Salir de pantalla completa diff --git a/library/ui/src/main/res/values-es/strings.xml b/library/ui/src/main/res/values-es/strings.xml index 898a2a79b2..a70b29f18e 100644 --- a/library/ui/src/main/res/values-es/strings.xml +++ b/library/ui/src/main/res/values-es/strings.xml @@ -28,7 +28,6 @@ Repetir todo Con reproducción aleatoria Sin reproducción aleatoria - Modo de pantalla completa Modo RV Descargar Descargas @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Pista de audio + Velocidad de reproducción + Normal + Ir a la lista anterior de botones + Ver más botones + Progreso de reproducción + Ajustes + Toca para ocultar subtítulos + Toca para mostrar subtítulos + Retroceder %d segundos + Avanzar %d segundos + Activar pantalla completa + Salir de pantalla completa diff --git a/library/ui/src/main/res/values-et/strings.xml b/library/ui/src/main/res/values-et/strings.xml index 0c6d3234ef..1eeefbfd95 100644 --- a/library/ui/src/main/res/values-et/strings.xml +++ b/library/ui/src/main/res/values-et/strings.xml @@ -28,7 +28,6 @@ Korda kõiki Lülita juh. järj. esit. sisse Lülita juh. järj. esit. välja - Täisekraani režiim VR-režiim Allalaadimine Allalaadimised @@ -54,4 +53,17 @@ Subtiitrid %1$.2f Mbit/s %1$s, %2$s + Heliriba + Taasesituskiirus + Tavaline + Tagasi eelmise nupuloendi juurde + Kuva rohkem nuppe + Taasesitus on pooleli + Seaded + Puudutage subtiitrite peitmiseks + Puudutage subtiitrite kuvamiseks + Keri %d sekundit tagasi + Keri %d sekundit edasi + Avamine täisekraanil + Väljumine täisekraanilt diff --git a/library/ui/src/main/res/values-eu/strings.xml b/library/ui/src/main/res/values-eu/strings.xml index 037c6f5f0d..408e5c361b 100644 --- a/library/ui/src/main/res/values-eu/strings.xml +++ b/library/ui/src/main/res/values-eu/strings.xml @@ -28,8 +28,7 @@ Errepikatu guztiak Ausazko erreprodukzioa aktibatuta Ausazko erreprodukzioa desaktibatuta - Pantaila osoko modua - EB modua + EBko modua Deskargak Deskargak Deskargatzen @@ -54,4 +53,17 @@ Azpitituluak %1$.2f Mb/s %1$s, %2$s + Audio-pista + Erreprodukzioaren abiadura + Normala + Itzuli aurreko botoi-zerrendara + Ikusi botoi gehiago + Erreprodukzioaren garapena + Ezarpenak + Sakatu azpitituluak ezkutatzeko + Sakatu azpitituluak erakusteko + Atzeratu %d segundo + Aurreratu %d segundo + Erreproduzitu pantaila osoan + Irten pantaila osotik diff --git a/library/ui/src/main/res/values-fa/strings.xml b/library/ui/src/main/res/values-fa/strings.xml index 7a5a34da46..ea75d8ec9f 100644 --- a/library/ui/src/main/res/values-fa/strings.xml +++ b/library/ui/src/main/res/values-fa/strings.xml @@ -28,7 +28,6 @@ تکرار همه پخش درهم روشن پخش درهم خاموش - حالت تمام‌صفحه حالت واقعیت مجازی بارگیری بارگیری‌ها @@ -54,4 +53,17 @@ زیرنویس ناشنوایان %1$.2f مگابیت در ثانیه %1$s،‏ %2$s + قطعه + سرعت بازپخش + عادی + فهرست دکمه برگشت به عقب + مشاهده دکمه‌های بیشتر + پیشرفت بازپخش + تنظیمات + برای پنهان کردن زیرنویس ضربه بزنید + برای نمایش زیرنویس ضربه بزنید + عقب بردن %d ثانیه + جلو بردن سریع %d ثانیه + ورود به حالت تمام‌صفحه + خروج از حالت تمام‌صفحه diff --git a/library/ui/src/main/res/values-fi/strings.xml b/library/ui/src/main/res/values-fi/strings.xml index 4c913fbb8b..aadfd9178f 100644 --- a/library/ui/src/main/res/values-fi/strings.xml +++ b/library/ui/src/main/res/values-fi/strings.xml @@ -28,7 +28,6 @@ Toista kaikki uudelleen Satunnaistoisto käytössä Satunnaistoisto ei käytössä - Koko näytön tila VR-tila Lataa Lataukset @@ -54,4 +53,17 @@ Tekstitykset %1$.2f Mt/s %1$s, %2$s + Ääniraita + Toistonopeus + Normaali + Palaa edellisiin painikkeisiin + Näytä lisää painikkeita + Toiston edistyminen + Asetukset + Piilota tekstitykset napauttamalla + Näytä tekstitykset napauttamalla + Kelaa taaksepäin %d sekuntia + Kelaa eteenpäin %d sekuntia + Siirry koko näytön tilaan + Sulje koko näytön tila diff --git a/library/ui/src/main/res/values-fr-rCA/strings.xml b/library/ui/src/main/res/values-fr-rCA/strings.xml index 9280e64223..09533174dd 100644 --- a/library/ui/src/main/res/values-fr-rCA/strings.xml +++ b/library/ui/src/main/res/values-fr-rCA/strings.xml @@ -28,7 +28,6 @@ Tout lire en boucle Lecture aléatoire activée Lecture aléatoire désactivée - Mode Plein écran Mode RV Télécharger Téléchargements @@ -54,4 +53,17 @@ Sous-titres codés %1$.2f Mb/s %1$s, %2$s + Piste audio + Vitesse de lecture + Normale + Retour à liste de boutons précéd. + Afficher plus de boutons + Progression de la lecture + Paramètres + Touchez pour masquer les sous-titres + Touchez pour afficher les sous-titres + Reculez de %d seconde + Avancez rapidement de %d secondes + Activez le mode plein écran + Quittez le mode plein écran diff --git a/library/ui/src/main/res/values-fr/strings.xml b/library/ui/src/main/res/values-fr/strings.xml index 94a72e2696..589aecac14 100644 --- a/library/ui/src/main/res/values-fr/strings.xml +++ b/library/ui/src/main/res/values-fr/strings.xml @@ -28,7 +28,6 @@ Tout lire en boucle Lecture aléatoire activée Lecture aléatoire désactivée - Mode plein écran Mode RV Télécharger Téléchargements @@ -54,4 +53,17 @@ Sous-titres %1$.2f Mbit/s %1$s, %2$s + Piste audio + Vitesse de lecture + Normale + Retour à liste boutons précédente + Afficher plus de boutons + Progression de la lecture + Paramètres + Appuyer pour masquer les sous-titres + Appuyer pour afficher les sous-titres + Revenir en arrière de %d secondes + Avancer de %d secondes + Activer le mode plein écran + Quitter le mode plein écran diff --git a/library/ui/src/main/res/values-gl/strings.xml b/library/ui/src/main/res/values-gl/strings.xml index 566f36893a..039fd32ca5 100644 --- a/library/ui/src/main/res/values-gl/strings.xml +++ b/library/ui/src/main/res/values-gl/strings.xml @@ -28,7 +28,6 @@ Repetir todas as pistas Reprodución aleatoria activada Reprodución aleat. desactivada - Modo de pantalla completa Modo RV Descargar Descargas @@ -54,4 +53,17 @@ Subtítulos %1$.2f Mbps %1$s, %2$s + Pista de audio + Velocidade de reprodución + Normal + Volver á lista anterior + Mostrar máis botóns + Progreso da reprodución + Configuración + Tocar para ocultar subtítulos + Tocar para mostrar subtítulos + Rebobinar %d segundos + Avanzar rapidamente %d segundos + Acceder a pantalla completa + Saír de pantalla completa diff --git a/library/ui/src/main/res/values-gu/strings.xml b/library/ui/src/main/res/values-gu/strings.xml index 2a628b3e92..791d0ec628 100644 --- a/library/ui/src/main/res/values-gu/strings.xml +++ b/library/ui/src/main/res/values-gu/strings.xml @@ -28,7 +28,6 @@ બધાને રિપીટ કરો શફલ ચાલુ છે શફલ બંધ છે - પૂર્ણસ્ક્રીન મોડ VR મોડ ડાઉનલોડ કરો ડાઉનલોડ @@ -36,8 +35,8 @@ ડાઉનલોડ પૂર્ણ થયું ડાઉનલોડ નિષ્ફળ થયું ડાઉનલોડ કાઢી નાખી રહ્યાં છીએ - વીડિઓ - ઑડિઓ + વીડિયો + ઑડિયો ટેક્સ્ટ એકપણ નહીં આપમેળે @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + ઑડિયો ટ્રૅક + પ્લેબૅકની ઝડપ + સામાન્ય + પહેલાંની બટન સૂચિ પર પાછા જાઓ + વધુ બટન જુઓ + પ્લેબૅક ચાલુ છે + સેટિંગ + સબટાઇટલ છુપાવવા માટે ટૅપ કરો + સબટાઇટલ બતાવવા માટે ટૅપ કરો + %d સેકન્ડ રિવાઇન્ડ કરો + %d સેકન્ડ ફાસ્ટ ફૉરવર્ડ કરો + પૂર્ણસ્ક્રીન મોડમાં દાખલ થાઓ + પૂર્ણસ્ક્રીન મોડમાંથી બહાર નીકળો diff --git a/library/ui/src/main/res/values-hi/strings.xml b/library/ui/src/main/res/values-hi/strings.xml index 546ead89c6..dd498e6406 100644 --- a/library/ui/src/main/res/values-hi/strings.xml +++ b/library/ui/src/main/res/values-hi/strings.xml @@ -28,7 +28,6 @@ सभी को दोहराएं शफ़ल करना चालू है शफ़ल करना बंद है - फ़ुलस्क्रीन मोड VR मोड डाउनलोड करें डाउनलोड की गई मीडिया फ़ाइलें @@ -54,4 +53,17 @@ सबटाइटल %1$.2f एमबीपीएस %1$s, %2$s + ऑडियो ट्रैक + वीडियो चलाने की रफ़्तार + सामान्य + बटन की पिछली सूची पर वापस जाएं + ज़्यादा बटन देखें + वीडियो चलने की प्रगति + सेटिंग + सबटाइटल छिपाने के लिए टैप करें + सबटाइटल देखने के लिए टैप करें + वीडियो को %d सेकंड पीछे ले जाएं + वीडियो को %d सेकंड आगे बढ़ाएं + फ़ुलस्क्रीन मोड पर देखें + फ़ुलस्क्रीन मोड से बाहर निकलें diff --git a/library/ui/src/main/res/values-hr/strings.xml b/library/ui/src/main/res/values-hr/strings.xml index a349fe917e..c0393c2a6e 100644 --- a/library/ui/src/main/res/values-hr/strings.xml +++ b/library/ui/src/main/res/values-hr/strings.xml @@ -28,7 +28,6 @@ Ponovi sve Nasumična reproduk. uključena Nasumična reproduk. isključena - Prikaz na cijelom zaslonu VR način Preuzmi Preuzimanja @@ -54,4 +53,17 @@ Verzija s titlovima %1$.2f Mbps %1$s, %2$s + Zvučni zapis + Brzina reprodukcije + Obično + Natrag na prethodni popis gumba + Pogledajte više gumba + Napredak reprodukcije + Postavke + Dodirnite za sakrivanje titlova + Dodirnite za prikazivanje titlova + Premotavanje %d s unatrag + Premotavanje %d s unaprijed + Otvaranje prikaza na cijelom zaslonu + Zatvaranje prikaza na cijelom zaslonu diff --git a/library/ui/src/main/res/values-hu/strings.xml b/library/ui/src/main/res/values-hu/strings.xml index 08bb764d1e..58311dea69 100644 --- a/library/ui/src/main/res/values-hu/strings.xml +++ b/library/ui/src/main/res/values-hu/strings.xml @@ -28,7 +28,6 @@ Összes szám ismétlése Keverés bekapcsolva Keverés kikapcsolva - Teljes képernyős mód VR-mód Letöltés Letöltések @@ -54,4 +53,17 @@ Felirat %1$.2f Mbps %1$s, %2$s + Hangsáv + Lejátszási sebesség + Normál + Vissza az előző gomblistára + További gombok megjelenítése + Lejátszási folyamat + Beállítások + A feliratok elrejtéséhez koppintson + A feliratok megjelenítéséhez koppintson + Visszatekerés %d másodperccel + Gyors előretekerés %d másodperccel + Teljes képernyő + Kilépés a teljes képernyős módból diff --git a/library/ui/src/main/res/values-hy/strings.xml b/library/ui/src/main/res/values-hy/strings.xml index d7e1b4078c..b32b4231b8 100644 --- a/library/ui/src/main/res/values-hy/strings.xml +++ b/library/ui/src/main/res/values-hy/strings.xml @@ -28,7 +28,6 @@ Կրկնել բոլորը Խառնումը միացված է Խառնումն անջատված է - Լիաէկրան ռեժիմ VR ռեժիմ Ներբեռնել Ներբեռնումներ @@ -54,4 +53,17 @@ Ենթագրեր %1$.2f Մբիթ/վ %1$s, %2$s + Ձայնային կատարում + Նվագարկման արագությունը + Սովորական + Անցնել կոճակների նախորդ ցանկին + Այլ կոճակներ + Նվագարկման ընթացքը + Կարգավորումներ + Հպեք՝ ենթագրերը թաքցնելու համար + Հպեք՝ ենթագրերը ցուցադրելու համար + %d վայրկյան հետ + %d վայրկյան առաջ + Մտնել լիաէկրան ռեժիմ + Դուրս գալ լիաէկրան ռեժիմից diff --git a/library/ui/src/main/res/values-in/strings.xml b/library/ui/src/main/res/values-in/strings.xml index 36e55347fd..843d5f02ca 100644 --- a/library/ui/src/main/res/values-in/strings.xml +++ b/library/ui/src/main/res/values-in/strings.xml @@ -28,7 +28,6 @@ Ulangi semua Acak aktif Acak tidak aktif - Mode layar penuh Mode VR Download Download @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Trek audio + Kecepatan pemutaran + Normal + Daftar tombol sebelumnya + Lihat tombol lainnya + Progres pemutaran + Setelan + Ketuk untuk menyembunyikan subtitel + Ketuk untuk menampilkan subtitel + Mundur %d detik + Maju cepat %d detik + Masuk layar penuh + Keluar dari layar penuh diff --git a/library/ui/src/main/res/values-is/strings.xml b/library/ui/src/main/res/values-is/strings.xml index 669e52e5b6..92e73d53e2 100644 --- a/library/ui/src/main/res/values-is/strings.xml +++ b/library/ui/src/main/res/values-is/strings.xml @@ -28,7 +28,6 @@ Endurtaka allt Kveikt á stokkun Slökkt á stokkun - Allur skjárinn sýndarveruleikastilling Sækja Niðurhal @@ -54,4 +53,17 @@ Skjátextar %1$.2f Mb/sek. %1$s, %2$s + Hljóðrás + Spilunarhraði + Venjulegur + Aftur á fyrri hnappalista + Sjá fleiri hnappa + Framvinda spilunar + Stillingar + Ýttu til að fela skjátexta + Ýttu til að sýna skjátexta + Spóla aftur um %d sekúndur + Spóla fram um %d sekúndur + Nota allan skjáinn + Hætta að nota allan skjáinn diff --git a/library/ui/src/main/res/values-it/strings.xml b/library/ui/src/main/res/values-it/strings.xml index f2e1f16821..87d5fe2b12 100644 --- a/library/ui/src/main/res/values-it/strings.xml +++ b/library/ui/src/main/res/values-it/strings.xml @@ -28,7 +28,6 @@ Ripeti tutto Attiva riproduzione casuale Disattiva riproduzione casuale - Modalità a schermo intero Modalità VR Scarica Download @@ -54,4 +53,17 @@ Sottotitoli %1$.2f Mbps %1$s, %2$s + Traccia audio + Velocità di riproduzione + Normale + Torna a elenco pulsanti preced. + Visualizza altri pulsanti + Avanzamento della riproduzione + Impostazioni + Tocca per nascondere i sottotitoli + Tocca per mostrare i sottotitoli + Indietro di %d secondi + Avanti veloce di %d secondi + Attiva schermo intero + Esci da schermo intero diff --git a/library/ui/src/main/res/values-iw/strings.xml b/library/ui/src/main/res/values-iw/strings.xml index 0ef5ba02d6..e458f20dcc 100644 --- a/library/ui/src/main/res/values-iw/strings.xml +++ b/library/ui/src/main/res/values-iw/strings.xml @@ -28,7 +28,6 @@ חזור על הכול ההשמעה האקראית מופעלת ההשמעה האקראית מושבתת - מצב מסך מלא מצב VR הורדה הורדות @@ -54,4 +53,17 @@ כתוביות %1$.2f מגה סיביות לשנייה %1$s‏, %2$s + טראק של אודיו + מהירות הפעלה + רגילה + חזרה לרשימת הלחצנים הקודמת + הצגת לחצנים נוספים + התקדמות ההפעלה + הגדרות + יש להקיש כדי להסתיר את הכתוביות + יש להקיש כדי להציג את הכתוביות + הרצה %d שניות אחורה + הרצה %d שניות קדימה + כניסה למסך מלא + יציאה ממסך מלא diff --git a/library/ui/src/main/res/values-ja/strings.xml b/library/ui/src/main/res/values-ja/strings.xml index 0502edca1b..2b7ec2bd9f 100644 --- a/library/ui/src/main/res/values-ja/strings.xml +++ b/library/ui/src/main/res/values-ja/strings.xml @@ -28,7 +28,6 @@ 全曲をリピート シャッフル ON シャッフル OFF - 全画面モード VR モード ダウンロード ダウンロード @@ -54,4 +53,17 @@ 字幕 %1$.2f Mbps %1$s、%2$s + 音声トラック + 再生速度 + 標準 + 前のボタンリストに戻る + 他のボタンを見る + 再生中 + 設定 + タップすると、字幕が非表示になります + タップすると、字幕が表示されます + %d 秒巻き戻し + %d 秒早送り + 全画面表示に変更 + 全画面表示を終了 diff --git a/library/ui/src/main/res/values-ka/strings.xml b/library/ui/src/main/res/values-ka/strings.xml index 71cee1603f..2ea75fc6a3 100644 --- a/library/ui/src/main/res/values-ka/strings.xml +++ b/library/ui/src/main/res/values-ka/strings.xml @@ -28,7 +28,6 @@ ყველას გამეორება არეულად დაკვრა ჩართულია არეულად დაკვრა გამორთულია - სრულეკრანიანი რეჟიმი VR რეჟიმი ჩამოტვირთვა ჩამოტვირთვები @@ -54,4 +53,17 @@ დახურული სუბტიტრები %1$.2f მბიტ/წმ %1$s, %2$s + აუდიოჩანაწერი + დაკვრის სიჩქარე + ჩვეულებრივი + ღილაკების წინა სიაზე გადასვლა + სხვა ღილაკების ნახვა + დაკვრის პროგრესი + პარამეტრები + შეეხეთ სუბტიტრების დასამალად + შეეხეთ სუბტიტრების საჩვენებლად + უკან გადახვევა %d წამით + წინ გადახვევა %d წამით + სრულეკრანიან რეჟიმში შესვლა + სრულეკრანიანი რეჟიმიდან გამოსვლა diff --git a/library/ui/src/main/res/values-kk/strings.xml b/library/ui/src/main/res/values-kk/strings.xml index 56e4c9bd52..136b690c6c 100644 --- a/library/ui/src/main/res/values-kk/strings.xml +++ b/library/ui/src/main/res/values-kk/strings.xml @@ -28,7 +28,6 @@ Барлығын қайталау Араластыру режимі қосулы. Араластыру режимі өшірулі. - Толық экран режимі VR режимі Жүктеп алу Жүктеп алынғандар @@ -54,4 +53,17 @@ Субтитр %1$.2f МБ/сек %1$s, %2$s + Аудиотрек + Ойнату жылдамдығы + Қалыпты + Алдыңғы түймелер тізіміне оралу + Басқа түймелерді көру + Ойнату барысы + Параметрлер + Субтитрді жасыру үшін түртіңіз + Субтитрді көрсету үшін түртіңіз + %d секунд артқа айналдыру + %d секунд алға айналдыру + Толық экранды режимге кіру + Толық экранды режимнен шығу diff --git a/library/ui/src/main/res/values-km/strings.xml b/library/ui/src/main/res/values-km/strings.xml index 4c887b6bbc..90eddabf9b 100644 --- a/library/ui/src/main/res/values-km/strings.xml +++ b/library/ui/src/main/res/values-km/strings.xml @@ -28,7 +28,6 @@ លេង​ឡើងវិញ​ទាំងអស់ បើក​ការ​ច្របល់ បិទ​ការ​ច្របល់ - មុខងារពេញ​អេក្រង់ មុខងារ VR ទាញយក ទាញយក @@ -54,4 +53,17 @@ អក្សររត់ %1$.2f Mbps %1$s, %2$s + ភ្លេង + ល្បឿន​ចាក់ + ធម្មតា + ត្រឡប់ទៅ​បញ្ជី​ប៊ូតុង​មុនវិញ + មើលប៊ូតុងច្រើនទៀត + ការចាក់​កំពុង​ដំណើរការ + ការកំណត់ + ចុច ដើម្បី​លាក់អក្សររត់ + ចុច ដើម្បី​បង្ហាញអក្សររត់ + ខា​ថយក្រោយ %d វិនាទី + ខា​ទៅមុខ %d វិនាទី + ចូលអេក្រង់ពេញ + ចាកចេញពីអេក្រង់ពេញ diff --git a/library/ui/src/main/res/values-kn/strings.xml b/library/ui/src/main/res/values-kn/strings.xml index b7545d8d30..9c7e9f5efb 100644 --- a/library/ui/src/main/res/values-kn/strings.xml +++ b/library/ui/src/main/res/values-kn/strings.xml @@ -28,7 +28,6 @@ ಎಲ್ಲವನ್ನು ಪುನರಾವರ್ತಿಸಿ ಶಫಲ್ ಆನ್ ಆಗಿದೆ ಶಫಲ್ ಆಫ್ ಆಗಿದೆ - ಪೂರ್ಣ ಪರದೆ ಮೋಡ್ VR ಮೋಡ್ ಡೌನ್‌ಲೋಡ್‌ ಡೌನ್‌ಲೋಡ್‌ಗಳು @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + ಆಡಿಯೊ ಟ್ರ್ಯಾಕ್ + ಪ್ಲೇಬ್ಯಾಕ್ ವೇಗ + ಸಾಮಾನ್ಯ + ಹಿಂದಿನ ಬಟನ್ ಪಟ್ಟಿಗೆ ಹಿಂತಿರುಗಿ + ಇನ್ನಷ್ಟು ಬಟನ್‌ಗಳನ್ನು ವೀಕ್ಷಿಸಿ + ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಗತಿಯಲ್ಲಿದೆ + ಸೆಟ್ಟಿಂಗ್‌ಗಳು + ಉಪಶೀರ್ಷಿಕೆಗಳನ್ನು ಮರೆಮಾಡಲು ಟ್ಯಾಪ್ ಮಾಡಿ + ಉಪಶೀರ್ಷಿಕೆಗಳನ್ನು ತೋರಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ + %d ಸೆಕೆಂಡ್‌ಗಳಷ್ಟು ರಿವೈಂಡ್ ಮಾಡಿ + %d ಸೆಕೆಂಡ್‌ಗಳಷ್ಟು ಫಾಸ್ಟ್ ಫಾರ್ವರ್ಡ್ ಮಾಡಿ + ಫುಲ್‌ಸ್ಕ್ರೀನ್‌ಗೆ ಪ್ರವೇಶಿಸಿ + ಫುಲ್‌ಸ್ಕ್ರೀನ್‌ನಿಂದ ನಿರ್ಗಮಿಸಿ diff --git a/library/ui/src/main/res/values-ko/strings.xml b/library/ui/src/main/res/values-ko/strings.xml index 88833a880a..7c9db27dbf 100644 --- a/library/ui/src/main/res/values-ko/strings.xml +++ b/library/ui/src/main/res/values-ko/strings.xml @@ -28,7 +28,6 @@ 모두 반복 셔플 사용 셔플 사용 안함 - 전체화면 모드 가상 현실 모드 다운로드 다운로드 @@ -38,7 +37,7 @@ 다운로드 항목 삭제 중 동영상 오디오 - 문자 메시지 + 텍스트 없음 자동 알 수 없음 @@ -54,4 +53,17 @@ 자막 %1$.2fMbps %1$s, %2$s + 오디오 트랙 + 재생 속도 + 일반 + 이전 버튼 목록으로 돌아가기 + 버튼 더보기 + 재생 진행률 + 설정 + 탭하여 자막 숨기기 + 탭하여 자막 표시 + %d초 되감기 + %d초 앞으로 이동 + 전체 화면으로 전환 + 전체 화면 종료 diff --git a/library/ui/src/main/res/values-ky/strings.xml b/library/ui/src/main/res/values-ky/strings.xml index 2490c185b8..7fc27f31ea 100644 --- a/library/ui/src/main/res/values-ky/strings.xml +++ b/library/ui/src/main/res/values-ky/strings.xml @@ -28,7 +28,6 @@ Баарын кайталоо Аралаштыруу күйүк Аралаштыруу өчүк - Толук экран режими VR режими Жүктөп алуу Жүктөлүп алынгандар @@ -54,4 +53,17 @@ Коштомо жазуулар %1$.2f Мб/сек. %1$s, %2$s + Аудиотрек + Ойнотуу ылдамдыгы + Орточо + Мурунку баскыч тизмесине кайтуу + Дагы баскычтарды көрүү + Ойнотуу көрсөткүчү + Жөндөөлөр + Коштомо жазууларды жашыруу үчүн басыңыз + Коштомо жазууларды көрсөтүү үчүн басыңыз + %d секунд артка түрдүрүү + %d секунд алдыга түрдүрүү + Толук экранга кирүү + Толук экран режиминен чыгуу diff --git a/library/ui/src/main/res/values-lo/strings.xml b/library/ui/src/main/res/values-lo/strings.xml index 0a2106b906..ab27c197e0 100644 --- a/library/ui/src/main/res/values-lo/strings.xml +++ b/library/ui/src/main/res/values-lo/strings.xml @@ -28,7 +28,6 @@ ຫຼິ້ນຊ້ຳທັງໝົດ ເປີດການສຸ່ມເພງແລ້ວ ປິດການສຸ່ມເພງແລ້ວ - ໂໝດເຕັມຈໍ ໂໝດ VR ດາວໂຫລດ ດາວໂຫລດ @@ -54,4 +53,17 @@ ຄຳບັນຍາຍ %1$.2f Mbps %1$s, %2$s + ແທຣັກສຽງ + ຄວາມໄວການສາຍ + ທົ່ວໄປ + ກັບໄປທີ່ລາຍຊື່ປຸ່ມກ່ອນໜ້າ + ເບິ່ງປຸ່ມເພີ່ມເຕີມ + ສະຖານະການຫຼິ້ນ + ຕັ້ງຄ່າ + ແຕະເພື່ອເຊື່ອງຄຳແປ + ແຕະເພື່ອສະແດງຄຳແປ + ຖອຍຫຼັງ %d ວິນາທີ + ເລື່ອນໄປໜ້າ %d ວິນາທີ + ເຂົ້າສູ່ໂໝດເຕັມຈໍ + ອອກຈາກເຕັມຈໍ diff --git a/library/ui/src/main/res/values-lt/strings.xml b/library/ui/src/main/res/values-lt/strings.xml index c8bfc6d993..a1c0183147 100644 --- a/library/ui/src/main/res/values-lt/strings.xml +++ b/library/ui/src/main/res/values-lt/strings.xml @@ -28,7 +28,6 @@ Kartoti viską Maišymas įjungtas Maišymas išjungtas - Viso ekrano režimas VR režimas Atsisiųsti Atsisiuntimai @@ -54,4 +53,17 @@ Subtitrai %1$.2f Mb/s %1$s, %2$s + Garso takelis + Atkūrimo sparta + Įprasta + Atgal į ankstesnių mygtukų sąr. + Žr. daugiau mygtukų + Atkūrimo eiga + Nustatymai + Palieskite, kad paslėptumėte subtitrus + Palieskite, kad rodytumėte subtitrus + Atsukti %d sek. + Prasukti %d sek. + Įjungti viso ekrano režimą + Išjungti viso ekrano režimą diff --git a/library/ui/src/main/res/values-lv/strings.xml b/library/ui/src/main/res/values-lv/strings.xml index 5b3271a71e..efaa5311c3 100644 --- a/library/ui/src/main/res/values-lv/strings.xml +++ b/library/ui/src/main/res/values-lv/strings.xml @@ -28,7 +28,6 @@ Atkārtot visu Atsk. jauktā secībā ieslēgta Atsk. jauktā secībā izslēgta - Pilnekrāna režīms VR režīms Lejupielādēt Lejupielādes @@ -54,4 +53,17 @@ Slēgtie paraksti %1$.2f Mb/s %1$s, %2$s + Audio ieraksts + Atskaņošanas ātrums + Parasts + Uz iepriekšējo pogu sarakstu + Skatīt citas pogas + Atskaņošanas norise + Iestatījumi + Pieskarties, lai paslēptu subtitrus + Pieskarties, lai rādītu subtitrus + Attīt atpakaļ par %d sekundēm + Pārtīt %d sekundes uz priekšu + Atvērt pilnekrāna režīmu + Aizvērt pilnekrāna režīmu diff --git a/library/ui/src/main/res/values-mk/strings.xml b/library/ui/src/main/res/values-mk/strings.xml index 437f656582..14cba977b8 100644 --- a/library/ui/src/main/res/values-mk/strings.xml +++ b/library/ui/src/main/res/values-mk/strings.xml @@ -28,7 +28,6 @@ Повтори ги сите Мешањето е вклучено Мешањето е исклучено - Режим на цел екран Режим на VR Преземи Преземања @@ -54,4 +53,17 @@ Титлови %1$.2f Mб/с %1$s, %2$s + Аудиозапис + Брзина на репродукцијата + Нормална + Кон список со претходно копче + Прикажи повеќе копчиња + Напредок на репродукцијата + Поставки + Допрете за да се сокријат титловите + Допрете за да се прикажат титловите + Премотајте %d секунди наназад + Премотајте %d секунди нанапред + Приказ на цел екран + Излезете од цел екран diff --git a/library/ui/src/main/res/values-ml/strings.xml b/library/ui/src/main/res/values-ml/strings.xml index 82905cf3d3..8c8d048b23 100644 --- a/library/ui/src/main/res/values-ml/strings.xml +++ b/library/ui/src/main/res/values-ml/strings.xml @@ -28,7 +28,6 @@ എല്ലാം ആവർത്തിക്കുക ഇടകലർത്തൽ ഓണാക്കുക ഇടകലർത്തൽ ഓഫാക്കുക - പൂർണ്ണ സ്‌ക്രീൻ മോഡ് VR മോഡ് ഡൗൺലോഡ് ഡൗൺലോഡുകൾ @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + ഓഡിയോ ട്രാക്ക് + പ്ലേബാക്ക് വേഗത + സാധാരണം + മുൻ ബട്ടൺ ലിസ്റ്റിലേക്ക് മടങ്ങുക + കൂടുതൽ ബട്ടണുകൾ കാണുക + പ്ലേബാക്ക് പുരോഗതി + ക്രമീകരണം + സബ്‌ടൈറ്റിലുകൾ മറയ്‌ക്കാൻ ടാപ്പ് ചെയ്യുക + സബ്‌ടൈറ്റിലുകൾ കാണിക്കാൻ ടാപ്പ് ചെയ്യുക + %d സെക്കൻഡ് പിന്നോട്ട് നീക്കുക + വേഗത്തിൽ %d സെക്കൻഡ് മുന്നോട്ട് നീക്കുക + പൂർണ്ണ സ്‌ക്രീൻ ആക്കുക + പൂർണ്ണ സ്‌ക്രീനിൽ നിന്ന് പുറത്തുകടക്കുക diff --git a/library/ui/src/main/res/values-mn/strings.xml b/library/ui/src/main/res/values-mn/strings.xml index 076b078cc5..666d06797f 100644 --- a/library/ui/src/main/res/values-mn/strings.xml +++ b/library/ui/src/main/res/values-mn/strings.xml @@ -28,7 +28,6 @@ Бүгдийг нь дахин тоглуулах Холих асаалттай Холих унтраалттай - Бүтэн дэлгэцийн горим VR горим Татах Татaлт @@ -54,4 +53,17 @@ Хаалттай тайлбар %1$.2f Mbps %1$s, %2$s + Аудио зам + Дахин тоглуулах хурд + Хэвийн + Өмнөх товчлуурын жагсаалтад буцах + Бусад товчлуурыг харах + Дахин тоглуулах явц + Тохиргоо + Хадмалыг нуухын тулд товших + Хадмалыг харуулахын тулд товших + %d секунд ухраах + %d секунд хурдан урагшлуулах + Бүтэн дэлгэцээр харах + Бүтэн дэлгэцээс гарах diff --git a/library/ui/src/main/res/values-mr/strings.xml b/library/ui/src/main/res/values-mr/strings.xml index e5069ce8c9..81b4021bdb 100644 --- a/library/ui/src/main/res/values-mr/strings.xml +++ b/library/ui/src/main/res/values-mr/strings.xml @@ -28,7 +28,6 @@ सर्व रीपीट करा शफल करा सुरू करा शफल करा बंद करा - फुल स्क्रीन मोड VR मोड डाउनलोड करा डाउनलोड @@ -54,4 +53,17 @@ सबटायटल %1$.2f Mbps %1$s, %2$s + ऑडिओ ट्रॅक + प्लेबॅकचा वेग + सामान्य + बटणांच्या मागील सूचीवर परत जा + आणखी बटणे पाहा + प्लेबॅकची प्रगती + सेटिंग्ज + सबटायटल लपवण्यासाठी टॅप करा + सबटायटल दाखवण्यासाठी टॅप करा + %d सेकंद रीवाइंड करा + %d सेकंद फास्ट फॉरवर्ड करा + फुलस्क्रीनवर पाहा + फुलस्क्रीनवरून बाहेर पडा diff --git a/library/ui/src/main/res/values-ms/strings.xml b/library/ui/src/main/res/values-ms/strings.xml index 08b7804190..c308a7a6c3 100644 --- a/library/ui/src/main/res/values-ms/strings.xml +++ b/library/ui/src/main/res/values-ms/strings.xml @@ -28,7 +28,6 @@ Ulang semua Hidupkan rombak Matikan rombak - Mod skrin penuh Mod VR Muat turun Muat turun @@ -54,4 +53,17 @@ SK %1$.2f Mbps %1$s, %2$s + Runut audio + Kelajuan main balik + Biasa + Kembali ke senarai butang terdahulu + Lihat lagi butang + Kemajuan main balik + Tetapan + Ketik untuk sembunyikan sari kata + Ketik untuk menunjukkan sari kata + Mandir %d saat + Mundar laju %d saat + Masuk ke skrin penuh + Keluar dari skrin penuh diff --git a/library/ui/src/main/res/values-my/strings.xml b/library/ui/src/main/res/values-my/strings.xml index 7fb3213d1a..9814995cd3 100644 --- a/library/ui/src/main/res/values-my/strings.xml +++ b/library/ui/src/main/res/values-my/strings.xml @@ -28,7 +28,6 @@ အားလုံး ပြန်ကျော့ရန် ရောသမမွှေကို ဖွင့်ထားသည် ရောသမမွှေကို ပိတ်ထားသည် - မျက်နှာပြင်အပြည့် မုဒ် VR မုဒ် ဒေါင်းလုဒ် လုပ်ရန် ဒေါင်းလုဒ်များ @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s၊ %2$s + သီချင်း + ဖွင့်ရန် အမြန်နှုန်း + ပုံမှန် + ယခင်ခလုတ်စာရင်းသို့ ပြန်သွားရန် + နောက်ထပ်ခလုတ်များကို ကြည့်ရန် + ဖွင့်သည့် အနေအထား + ဆက်တင်များ + စာတန်းထိုးများဖျောက်ထားရန် တို့ပါ + စာတန်းထိုးများပြရန် တို့ပါ + နောက်သို့ %d စက္ကန့်ရစ်ရန် + ရှေ့သို့ %d စက္ကန့်ရစ်ရန် + ဖန်သားပြင်အပြည့်သို့ ဝင်ရောက်ပါ + ဖန်သားပြင်အပြည့်မှ ထွက်ရန် diff --git a/library/ui/src/main/res/values-nb/strings.xml b/library/ui/src/main/res/values-nb/strings.xml index 0a9fa97cb3..8801802120 100644 --- a/library/ui/src/main/res/values-nb/strings.xml +++ b/library/ui/src/main/res/values-nb/strings.xml @@ -28,7 +28,6 @@ Gjenta alle Tilfeldig rekkefølge er på Tilfeldig rekkefølge er av - Fullskjermmodus VR-modus Last ned Nedlastinger @@ -54,4 +53,17 @@ Teksting %1$.2f Mbps %1$s, %2$s + Lydspor + Avspillingshastighet + Normal + Tilbake til forrige knappeliste + Se flere knapper + Avspillingsfremdrift + Innstillinger + Trykk for å skjule undertekster + Trykk for å vise undertekster + Spol %d sekunder bakover + Spol %d sekunder fremover + Se i fullskjerm + Avslutt fullskjerm diff --git a/library/ui/src/main/res/values-ne/strings.xml b/library/ui/src/main/res/values-ne/strings.xml index 1297b5980d..2d655868b6 100644 --- a/library/ui/src/main/res/values-ne/strings.xml +++ b/library/ui/src/main/res/values-ne/strings.xml @@ -28,7 +28,6 @@ सबै दोहोर्‍याउनुहोस् मिसाउने सुविधा सक्रिय छ मिसाउने सुविधा निष्क्रिय छ - पूर्ण स्क्रिन मोड VR मोड डाउनलोड गर्नुहोस् डाउनलोडहरू @@ -54,4 +53,17 @@ उपशीर्षकहरू %1$.2f Mbps %1$s, %2$s + अडियो ट्र्याक + प्लेब्याकको गति + सामान्य + बटनको अघिल्लो सूचीमा फर्कनुहोस् + थप बटनहरू हेर्नुहोस् + हालसम्म भिडियो प्ले भएको अवधि + सेटिङ + सबटाइटल लुकाउन ट्याप गर्नुहोस् + सबटाइटल देखिने बनाउन ट्याप गर्नुहोस् + %d सेकेन्ड पछाडि जानुहोस् + %d सेकेन्ड अगाडि जानुहोस् + पूर्ण स्क्रिन मोडमा खोल्नुहोस् + पूर्ण स्क्रिन मोडबाट बाहिरिनुहोस् diff --git a/library/ui/src/main/res/values-nl/strings.xml b/library/ui/src/main/res/values-nl/strings.xml index 621a6a3184..ccf3183045 100644 --- a/library/ui/src/main/res/values-nl/strings.xml +++ b/library/ui/src/main/res/values-nl/strings.xml @@ -28,7 +28,6 @@ Alles herhalen Shuffle aan Shuffle uit - Modus \'Volledig scherm\' VR-modus Downloaden Downloads @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Audiotrack + Afspeelsnelheid + Normaal + Terug naar vorige knoppenlijst + Meer knoppen bekijken + Afspeelvoortgang + Instellingen + Tik om de ondertiteling te verbergen + Tik om de ondertiteling weer te geven + %d seconden terugspoelen + %d seconden vooruitspoelen + Volledig scherm openen + Volledig scherm sluiten diff --git a/library/ui/src/main/res/values-pa/strings.xml b/library/ui/src/main/res/values-pa/strings.xml index ccf149906a..952f313773 100644 --- a/library/ui/src/main/res/values-pa/strings.xml +++ b/library/ui/src/main/res/values-pa/strings.xml @@ -28,7 +28,6 @@ ਸਾਰਿਆਂ ਨੂੰ ਦੁਹਰਾਓ \'ਬੇਤਰਤੀਬ ਕਰੋ\' ਮੋਡ ਚਾਲੂ ਹੈ \'ਬੇਤਰਤੀਬ ਕਰੋ\' ਮੋਡ ਬੰਦ ਹੈ - ਪੂਰੀ-ਸਕ੍ਰੀਨ ਮੋਡ VR ਮੋਡ ਡਾਊਨਲੋਡ ਕਰੋ ਡਾਊਨਲੋਡ @@ -54,4 +53,17 @@ ਬੰਦ ਸੁੁਰਖੀਆਂ %1$.2f Mbps %1$s, %2$s + ਆਡੀਓ ਟਰੈਕ + ਪਲੇਬੈਕ ਦੀ ਗਤੀ + ਸਧਾਰਨ + ਪਿਛਲੀ ਬਟਨ ਸੂਚੀ \'ਤੇ ਵਾਪਸ ਜਾਓ + ਹੋਰ ਬਟਨ ਦੇਖੋ + ਪਲੇਬੈਕ ਪ੍ਰਗਤੀ + ਸੈਟਿੰਗਾਂ + ਉਪਸਿਰਲੇਖਾਂ ਨੂੰ ਲੁਕਾਉਣ ਲਈ ਟੈਪ ਕਰੋ + ਉਪਸਿਰਲੇਖਾਂ ਨੂੰ ਦਿਖਾਉਣ ਲਈ ਟੈਪ ਕਰੋ + %d ਸਕਿੰਟ ਪਿੱਛੇ ਕਰੋ + ਤੇਜ਼ੀ ਨਾਲ %d ਸਕਿੰਟ ਅੱਗੇ ਕਰੋ + ਪੂਰੀ ਸਕ੍ਰੀਨ ਦਾਖਲ ਕਰੋ + ਪੂਰੀ-ਸਕ੍ਰੀਨ ਤੋਂ ਬਾਹਰ ਜਾਓ diff --git a/library/ui/src/main/res/values-pl/strings.xml b/library/ui/src/main/res/values-pl/strings.xml index 26055e5895..2c6cc1fcdb 100644 --- a/library/ui/src/main/res/values-pl/strings.xml +++ b/library/ui/src/main/res/values-pl/strings.xml @@ -28,7 +28,6 @@ Powtórz wszystkie Włącz odtwarzanie losowe Wyłącz odtwarzanie losowe - Tryb pełnoekranowy Tryb VR Pobierz Pobieranie @@ -54,4 +53,17 @@ Napisy %1$.2f Mb/s %1$s, %2$s + Ścieżka audio + Szybkość odtwarzania + Normalna + Wróć do poprzedniej listy przycisków + Pokaż więcej przycisków + Postęp odtwarzania + Ustawienia + Kliknij, by ukryć napisy + Kliknij, by wyświetlić napisy + Przewiń do tyłu o %d s + Przewiń do przodu o %d s + Otwórz pełny ekran + Zamknij pełny ekran diff --git a/library/ui/src/main/res/values-pt-rPT/strings.xml b/library/ui/src/main/res/values-pt-rPT/strings.xml index 80fea42ca9..b452a4864e 100644 --- a/library/ui/src/main/res/values-pt-rPT/strings.xml +++ b/library/ui/src/main/res/values-pt-rPT/strings.xml @@ -18,7 +18,7 @@ Ocultar controlos do leitor Faixa anterior Faixa seguinte - Colocar em pausa + Pausar Reproduzir Parar Recuar @@ -28,7 +28,6 @@ Repetir tudo Reprodução aleatória ativada Reprodução aleatória desativ. - Modo de ecrã inteiro Modo de RV Transferir Transferências @@ -54,4 +53,17 @@ Legendas %1$.2f Mbps %1$s, %2$s + Faixa de áudio + Velocidade de reprodução + Normal + Voltar à lista de botões anter. + Ver mais botões + Progresso da reprodução + Definições + Tocar para ocultar as legendas + Tocar para mostrar as legendas + Retroceder %d segundos + Avançar %d segundos + Mudar para ecrã inteiro + Sair do ecrã inteiro diff --git a/library/ui/src/main/res/values-pt/strings.xml b/library/ui/src/main/res/values-pt/strings.xml index 703da1f5da..830253b679 100644 --- a/library/ui/src/main/res/values-pt/strings.xml +++ b/library/ui/src/main/res/values-pt/strings.xml @@ -28,7 +28,6 @@ Repetir tudo Ordem aleatória ativada Ordem aleatória desativada - Modo de tela cheia Modo RV Fazer o download Downloads @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Faixa de áudio + Velocidade da reprodução + Normal + Lista de botões anterior + Ver mais botões + Andamento da reprodução + Configurações + Toque para ocultar legendas + Toque para mostrar legendas + Retroceder %d segundos + Avançar %d segundos + Entrar no modo de tela cheia + Sair do modo de tela cheia diff --git a/library/ui/src/main/res/values-ro/strings.xml b/library/ui/src/main/res/values-ro/strings.xml index 379c7a75c0..5f8d4dd187 100644 --- a/library/ui/src/main/res/values-ro/strings.xml +++ b/library/ui/src/main/res/values-ro/strings.xml @@ -28,7 +28,6 @@ Repetați-le pe toate Redare aleatorie activată Redare aleatorie dezactivată - Modul Ecran complet Mod RV Descărcați Descărcări @@ -54,4 +53,17 @@ Subtitrări %1$.2f Mbps %1$s, %2$s + Înregistrare audio + Viteza de redare + Normală + Lista anterioară de butoane + Vedeți mai multe butoane + Progresul redării + Setări + Atingeți pentru a ascunde subtitrările. + Atingeți pentru a afișa subtitrările. + Derulați înapoi cu %d secunde + Derulați rapid înainte cu %d secunde + Accesați în ecran complet + Ieșiți din ecranul complet diff --git a/library/ui/src/main/res/values-ru/strings.xml b/library/ui/src/main/res/values-ru/strings.xml index 2ac176c984..a4c3c2b6e7 100644 --- a/library/ui/src/main/res/values-ru/strings.xml +++ b/library/ui/src/main/res/values-ru/strings.xml @@ -28,7 +28,6 @@ Повторять все Перемешивание включено Перемешивание отключено - Полноэкранный режим VR-режим Скачать Скачивания @@ -54,4 +53,17 @@ Субтитры %1$.2f Мбит/сек %1$s, %2$s + Звуковая дорожка + Скорость воспроизведения + Стандартная + Вернуться к предыдущему списку кнопок + Открыть список других кнопок + Полоса прокрутки воспроизведения + Настройки + Нажмите, чтобы скрыть субтитры + Нажмите, чтобы показать субтитры + Перемотать на %d с назад + Перемотать на %d с вперед + Полноэкранный режим + Выйти из полноэкранного режима diff --git a/library/ui/src/main/res/values-si/strings.xml b/library/ui/src/main/res/values-si/strings.xml index d5b4f7279a..7c6593d8b9 100644 --- a/library/ui/src/main/res/values-si/strings.xml +++ b/library/ui/src/main/res/values-si/strings.xml @@ -28,7 +28,6 @@ සියල්ල පුනරාවර්තනය කරන්න කලවම් කිරීම ක්‍රියාත්මකයි කලවම් කිරීම ක්‍රියා විරහිතයි - සම්පූර්ණ තිර ප්‍රකාරය VR ප්‍රකාරය බාගන්න බාගැනීම් @@ -54,4 +53,17 @@ සිරස්තල %1$.2f Mbps %1$s, %2$s + ඕඩියෝ ඛණ්ඩය + පසුධාවන වේගය + සාමාන්‍ය + පෙර බොත්තම් ලැයිස්තුවට ආපසු යන්න + තව බොත්තම් බලන්න + පසුධාවන ප්‍රගතිය + සැකසීම් + උප සිරැසි සැඟවීමට තට්ටු කරන්න + උප සිරැසි පෙන්වීමට තට්ටු කරන්න + තත්පර %dක් ආපස්සට යවන්න + තත්පර %dක් වේගයෙන් ඉදිරියට යවන්න + පූර්ණ තිරයට ඇතුළු වන්න + පූර්ණ තිරයෙන් පිට වන්න diff --git a/library/ui/src/main/res/values-sk/strings.xml b/library/ui/src/main/res/values-sk/strings.xml index 89f66fe253..59f42a62de 100644 --- a/library/ui/src/main/res/values-sk/strings.xml +++ b/library/ui/src/main/res/values-sk/strings.xml @@ -28,7 +28,6 @@ Opakovať všetko Náhodné prehrávanie je zapnuté Náhodné prehrávanie je vypnuté - Režim celej obrazovky režim VR Stiahnuť Stiahnuté @@ -54,4 +53,17 @@ Skryté titulky %1$.2f MB/s %1$s, %2$s + Zvuková stopa + Rýchlosť prehrávania + Normálna + Späť na predch. zoznam tlačidiel + Zobraziť ďalšie tlačidlá + Priebeh prehrávania + Nastavenia + Klepnutím skryjete titulky + Klepnutím zobrazíte titulky + Pretočiť späť o %d s + Pretočiť dopredu o %d s + Prejsť do režimu celej obrazovky + Ukončiť režim celej obrazovky diff --git a/library/ui/src/main/res/values-sl/strings.xml b/library/ui/src/main/res/values-sl/strings.xml index 4eebc47196..295fe63147 100644 --- a/library/ui/src/main/res/values-sl/strings.xml +++ b/library/ui/src/main/res/values-sl/strings.xml @@ -28,7 +28,6 @@ Ponavljanje vseh Naklj. predvajanje vklopljeno Naklj. predvajanje izklopljeno - Celozaslonski način Način VR Prenos Prenosi @@ -54,4 +53,17 @@ Podnapisi %1$.2f Mb/s %1$s, %2$s + Zvočni posnetek + Hitrost predvajanja + Običajna + Nazaj na prejšnji seznam gumbov + Prikaz več gumbov + Potek predvajanja + Nastavitve + Dotaknite se za izklop podnapisov + Dotaknite se za vklop podnapisov + Premik nazaj za %d s + Premik naprej za %d s + Vstop v celozaslonski način + Izhod iz celozaslonskega načina diff --git a/library/ui/src/main/res/values-sq/strings.xml b/library/ui/src/main/res/values-sq/strings.xml index d53e9dad3a..f3d291c1da 100644 --- a/library/ui/src/main/res/values-sq/strings.xml +++ b/library/ui/src/main/res/values-sq/strings.xml @@ -28,7 +28,6 @@ Përsërit të gjitha Përzierja aktive Përzierja joaktive - Modaliteti me ekran të plotë Modaliteti RV Shkarko Shkarkimet @@ -54,4 +53,17 @@ Titrat %1$.2f Mbps %1$s, %2$s + Pjesë zanore + Shpejtësia e luajtjes + Normale + Kthehu te lista e butonave të mëparshëm + Shiko më shumë butona + Progresi i luajtjes + Cilësimet + Trokit për të fshehur titrat + Trokit për të shfaqur titrat + Shko prapa %d sekonda + Shpejt përpara %d sekonda + Hyr në luajtjen me ekran të plotë + Dil nga ekrani i plotë diff --git a/library/ui/src/main/res/values-sr/strings.xml b/library/ui/src/main/res/values-sr/strings.xml index abc6bce206..bae637bd5f 100644 --- a/library/ui/src/main/res/values-sr/strings.xml +++ b/library/ui/src/main/res/values-sr/strings.xml @@ -28,7 +28,6 @@ Понови све Насумично пуштање је укључено Насумично пуштање је искључено - Режим целог екрана ВР режим Преузми Преузимања @@ -54,4 +53,17 @@ Титл %1$.2f Mb/s %1$s, %2$s + Аудио снимак + Брзина репродукције + Уобичајена + Назад на претходну листу дугмади + Прикажи још дугмади + Напредовање репродукције + Подешавања + Додирните да би се титлови сакрили + Додирните да би се титлови приказивали + Премотај %d секунди уназад + Премотај %d секунди унапред + Пређи на цео екран + Изађи из целог екрана diff --git a/library/ui/src/main/res/values-sv/strings.xml b/library/ui/src/main/res/values-sv/strings.xml index b0a5b348fe..b9fc44273d 100644 --- a/library/ui/src/main/res/values-sv/strings.xml +++ b/library/ui/src/main/res/values-sv/strings.xml @@ -28,7 +28,6 @@ Upprepa alla Blanda spår Blanda inte spår - Helskärmsläge VR-läge Ladda ned Nedladdningar @@ -54,4 +53,17 @@ Undertexter %1$.2f Mbit/s %1$s, %2$s + Ljudspår + Uppspelningshastighet + Normal + Öppna föregående knapplista + Visa fler knappar + Uppspelningsförlopp + Inställningar + Dölj undertexterna genom att trycka här + Visa undertexter genom att trycka här + Spola tillbaka %d sekunder + Spola framåt %d sekunder + Aktivera helskärm + Lämna helskärm diff --git a/library/ui/src/main/res/values-sw/strings.xml b/library/ui/src/main/res/values-sw/strings.xml index 285dc9a627..656a5df088 100644 --- a/library/ui/src/main/res/values-sw/strings.xml +++ b/library/ui/src/main/res/values-sw/strings.xml @@ -28,7 +28,6 @@ Rudia zote Hali ya kuchanganya imewashwa Hali ya kuchanganya imezimwa - Hali ya skrini nzima Hali ya Uhalisia Pepe Pakua Vipakuliwa @@ -45,13 +44,26 @@ %1$d × %2$d Mono Stereo - Sauti ya mzunguko - Sauti ya mzunguko ya 5.1 - Sauti ya mzunguko ya 7.1 + Sauti inayozingira + Sauti inayozingira ya 5.1 + Sauti inayozingira ya 7.1 Mbadala Wa ziada Uchambuzi Manukuu Mbps %1$.2f %1$s, %2$s + Wimbo wa sauti + Kasi ya kucheza + Kawaida + Rudi kwenye orodha ya vitufe vya awali + Angalia vitufe vingine + Kiasi cha uchezaji + Mipangilio + Gusa ili ufiche manukuu + Gusa ili uonyeshe manukuu + Rudisha nyuma kwa sekunde %d + Sogeza mbele haraka kwa sekunde %d + Fungua skrini nzima + Funga skrini nzima diff --git a/library/ui/src/main/res/values-ta/strings.xml b/library/ui/src/main/res/values-ta/strings.xml index 019afa08bf..600f5e66dc 100644 --- a/library/ui/src/main/res/values-ta/strings.xml +++ b/library/ui/src/main/res/values-ta/strings.xml @@ -28,7 +28,6 @@ அனைத்தையும் மீண்டும் இயக்கு கலைத்துப் போடுதல்: ஆன் கலைத்துப் போடுதல்: ஆஃப் - முழுத்திரைப் பயன்முறை VR பயன்முறை பதிவிறக்கும் பட்டன் பதிவிறக்கங்கள் @@ -54,4 +53,17 @@ வசனம் %1$.2f மெ.பை./வி %1$s, %2$s + ஆடியோ டிராக் + வீடியோவின் இயக்க வேகம் + இயல்பு + முந்தைய பட்டன்களுக்குச் செல்லும் + மேலும் பட்டன்களைக் காட்டும் + வீடியோவின் இயக்க நிலை + அமைப்புகள் + வசனங்களை மறைக்க தட்டவும் + வசனங்களைக் காட்ட தட்டவும் + %d வினாடிகள் பின்செல்ல உதவும் பட்டன் + %d வினாடிகள் வேகமாக முன்செல்ல உதவும் பட்டன் + முழுத்திரையில் காட்ட உதவும் பட்டன் + முழுத்திரையை வெளியேற உதவும் பட்டன் diff --git a/library/ui/src/main/res/values-te/strings.xml b/library/ui/src/main/res/values-te/strings.xml index 89003bd531..99dccb9d08 100644 --- a/library/ui/src/main/res/values-te/strings.xml +++ b/library/ui/src/main/res/values-te/strings.xml @@ -28,7 +28,6 @@ అన్నింటినీ పునరావృతం చేయండి షఫుల్‌ను ఆన్ చేస్తుంది షఫుల్‌ను ఆఫ్ చేస్తుంది - పూర్తి స్క్రీన్ మోడ్ వర్చువల్ రియాలిటీ మోడ్ డౌన్‌లోడ్ చేయి డౌన్‌లోడ్‌లు @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + ఆడియో ట్రాక్ + ప్లేబ్యాక్ వేగం + సాధారణం + మునుపటి బటన్ జాబితాకు వెళ్తుంది + మరిన్ని బటన్‌లను చూడండి + ప్లేబ్యాక్ ప్రోగ్రెస్ + సెట్టింగ్‌లు + సబ్‌టైటిల్స్‌ను దాచడానికి ట్యాప్ చేయండి + సబ్‌టైటిల్స్‌ను చూపడానికి ట్యాప్ చేయండి + %d సెకన్లు రివైండ్ చేయండి + %d సెకన్లు వేగంగా ఫార్వార్డ్ చేయండి + ఫుల్ స్క్రీన్‌లోకి ప్రవేశించు + ఫుల్ స్క్రీన్ నుండి నిష్క్రమించు diff --git a/library/ui/src/main/res/values-th/strings.xml b/library/ui/src/main/res/values-th/strings.xml index be0d779679..5c2544d03b 100644 --- a/library/ui/src/main/res/values-th/strings.xml +++ b/library/ui/src/main/res/values-th/strings.xml @@ -28,7 +28,6 @@ เล่นซ้ำทั้งหมด เปิดการสุ่มเพลงอยู่ ปิดการสุ่มเพลงอยู่ - โหมดเต็มหน้าจอ โหมด VR ดาวน์โหลด ดาวน์โหลด @@ -54,4 +53,17 @@ คำบรรยาย %1$.2f Mbps %1$s, %2$s + แทร็กเสียง + ความเร็วในการเล่น + ปกติ + กลับไปที่รายการปุ่มก่อนหน้า + ดูปุ่มอื่นๆ + ความคืบหน้าในการเล่น + การตั้งค่า + แตะเพื่อซ่อนคำบรรยาย + แตะเพื่อแสดงคำบรรยาย + กรอกลับ %d วินาที + กรอไปข้างหน้า %d วินาที + แสดงแบบเต็มหน้าจอ + ออกจากโหมดเต็มหน้าจอ diff --git a/library/ui/src/main/res/values-tl/strings.xml b/library/ui/src/main/res/values-tl/strings.xml index 84c4afc99b..2b169ba8ca 100644 --- a/library/ui/src/main/res/values-tl/strings.xml +++ b/library/ui/src/main/res/values-tl/strings.xml @@ -28,7 +28,6 @@ Ulitin lahat Naka-on ang pag-shuffle Naka-off ang pag-shuffle - Fullscreen mode VR mode I-download Mga Download @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Audio track + Bilis ng pag-playback + Normal + Bumalik sa nakaraang button list + Tumingin pa ng mga button + Progreso ng pag-playback + Mga Setting + I-tap para itago ang mga subtitle + I-tap para ipakita ang mga subtitle + I-rewind ng %d (na) segundo + I-fast forward ng %d (na) segundo + Mag-fullscreen + Lumabas sa fullscreen diff --git a/library/ui/src/main/res/values-tr/strings.xml b/library/ui/src/main/res/values-tr/strings.xml index eee5e483db..e95892e285 100644 --- a/library/ui/src/main/res/values-tr/strings.xml +++ b/library/ui/src/main/res/values-tr/strings.xml @@ -28,7 +28,6 @@ Tümünü tekrarla Karıştırma açık Karıştırma kapalı - Tam ekran modu VR modu İndir İndirilenler @@ -54,4 +53,17 @@ Altyazı %1$.2f Mbps %1$s, %2$s + Ses parçası + Çalma hızı + Normal + Önceki düğme listesine dön + Diğer düğmeleri göster + Çalma ilerleme durumu + Ayarlar + Altyazıları gizlemek için dokunun + Altyazıları göstermek için dokunun + %d saniye geri sar + %d saniye ileri sar + Tam ekrana geç + Tam ekrandan çık diff --git a/library/ui/src/main/res/values-uk/strings.xml b/library/ui/src/main/res/values-uk/strings.xml index f265c6c8cd..310a7775c1 100644 --- a/library/ui/src/main/res/values-uk/strings.xml +++ b/library/ui/src/main/res/values-uk/strings.xml @@ -28,7 +28,6 @@ Повторити всі Перемішування ввімкнено Перемішування вимкнено - Повноекранний режим Режим віртуальної реальності Завантажити Завантаження @@ -54,4 +53,17 @@ Субтитри %1$.2f Мбіт/с %1$s, %2$s + Аудіодоріжка + Швидкість відтворення + Звичайна + Повернутися до попереднього списку кнопок + Показати інші кнопки + Прогрес відтворення + Налаштування + Натисніть, щоб сховати субтитри + Натисніть, щоб показати субтитри + Перемотати назад на %d с + Перемотати вперед на %d с + Перейти в повноекранний режим + Вийти з повноекранного режиму diff --git a/library/ui/src/main/res/values-ur/strings.xml b/library/ui/src/main/res/values-ur/strings.xml index da37ffbdab..2d085d7bf6 100644 --- a/library/ui/src/main/res/values-ur/strings.xml +++ b/library/ui/src/main/res/values-ur/strings.xml @@ -28,7 +28,6 @@ سبھی کو دہرائیں شفل آن شفل آف - پوری اسکرین والی وضع VR موڈ ڈاؤن لوڈ کریں ڈاؤن لوڈز @@ -54,4 +53,17 @@ سب ٹائٹلز %1$.2f Mbps %1$s، %2$s + آڈیو ٹریک + پلے بیک کی رفتار + عام + بٹنز کی پچھلی فہرست پر واپس جائیں + مزید بٹنز دیکھیں + پلے بیک کی پیش رفت + ترتیبات + سب ٹائٹلز کو چھپانے کے لیے تھپتھپائیں + سب ٹائٹلز کو دکھانے کے لیے تھپتھپائیں + %d سیکنڈز ریوائنڈ کریں + تیزی سے %d سیکنڈز فارورڈ کریں + پوری اسکرین میں داخل ہوں + پوری اسکرین سے باہر نکلیں diff --git a/library/ui/src/main/res/values-uz/strings.xml b/library/ui/src/main/res/values-uz/strings.xml index 97ffb66ad1..abd4a29ab0 100644 --- a/library/ui/src/main/res/values-uz/strings.xml +++ b/library/ui/src/main/res/values-uz/strings.xml @@ -28,7 +28,6 @@ Hammasini takrorlash Tasodifiy ijro yoqilgan Tasodifiy ijro yoqilmagan - Butun ekran rejimi VR rejimi Yuklab olish Yuklanmalar @@ -54,4 +53,17 @@ Taglavhalar %1$.2f Mbit/s %1$s, %2$s + Audio trek + Ijro tezligi + Normal + Avvalgi roʻyxatga qaytish + Boshqa tugmalar + Ijro rivoji + Sozlamalar + Taglavhalarni berkitish uchun bosing + Taglavhalarni chiqarish uchun bosing + %d soniya orqaga + %d soniya oldinga + Butun ekran rejimiga kirish + Butun ekran rejimidan chiqish diff --git a/library/ui/src/main/res/values-vi/strings.xml b/library/ui/src/main/res/values-vi/strings.xml index 7b6b2a4a39..b7f87899f8 100644 --- a/library/ui/src/main/res/values-vi/strings.xml +++ b/library/ui/src/main/res/values-vi/strings.xml @@ -28,7 +28,6 @@ Lặp lại tất cả Chế độ trộn bài đang bật Chế độ trộn bài đang tắt - Chế độ toàn màn hình Chế độ thực tế ảo Tải xuống Tài nguyên đã tải xuống @@ -54,4 +53,17 @@ Phụ đề %1$.2f Mb/giây %1$s, %2$s + Bản âm thanh + Tốc độ phát + Bình thường + Quay lại danh sách nút trước đó + Xem các nút khác + Tiến trình phát + Cài đặt + Nhấn để ẩn phụ đề + Nhấn để hiển thị phụ đề + Tua lại %d giây + Tua nhanh %d giây + Chuyển sang chế độ toàn màn hình + Thoát khỏi chế độ toàn màn hình diff --git a/library/ui/src/main/res/values-zh-rCN/strings.xml b/library/ui/src/main/res/values-zh-rCN/strings.xml index e55fa68afc..c4bc94c46b 100644 --- a/library/ui/src/main/res/values-zh-rCN/strings.xml +++ b/library/ui/src/main/res/values-zh-rCN/strings.xml @@ -16,8 +16,8 @@ 显示播放器控件 隐藏播放器控件 - 上一曲 - 下一曲 + 上一首 + 下一首 暂停 播放 停止 @@ -28,7 +28,6 @@ 全部重复播放 随机播放功能已开启 随机播放功能已关闭 - 全屏模式 VR 模式 下载 下载内容 @@ -54,4 +53,17 @@ 字幕 %1$.2f Mbps %1$s,%2$s + 音轨 + 播放速度 + 正常 + 返回上一个按钮列表 + 查看更多按钮 + 播放进度 + 设置 + 点按即可隐藏字幕 + 点按即可显示字幕 + 快退 %d 秒 + 快进 %d 秒 + 进入全屏模式 + 退出全屏模式 diff --git a/library/ui/src/main/res/values-zh-rHK/strings.xml b/library/ui/src/main/res/values-zh-rHK/strings.xml index 8906859405..9f67e36032 100644 --- a/library/ui/src/main/res/values-zh-rHK/strings.xml +++ b/library/ui/src/main/res/values-zh-rHK/strings.xml @@ -28,7 +28,6 @@ 全部重複播放 已開啟隨機播放功能 已關閉隨機播放功能 - 全螢幕模式 虛擬現實模式 下載 下載內容 @@ -54,4 +53,17 @@ 字幕 %1$.2f Mbps %1$s、%2$s + 音軌 + 播放速度 + 正常 + 返回上一個按鈕清單 + 查看更多按鈕 + 播放進度 + 設定 + 輕按即可隱藏字幕 + 輕按即可顯示字幕 + 倒帶 %d 秒 + 快轉 %d 秒 + 進入全螢幕 + 關閉全螢幕 diff --git a/library/ui/src/main/res/values-zh-rTW/strings.xml b/library/ui/src/main/res/values-zh-rTW/strings.xml index bc2ce89184..9d76d9e36f 100644 --- a/library/ui/src/main/res/values-zh-rTW/strings.xml +++ b/library/ui/src/main/res/values-zh-rTW/strings.xml @@ -28,7 +28,6 @@ 重複播放所有項目 隨機播放已開啟 隨機播放已關閉 - 全螢幕模式 虛擬實境模式 下載 下載 @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s、%2$s + 音軌 + 播放速度 + 正常 + 返回先前的按鈕清單 + 顯示更多按鈕 + 播放進度 + 設定 + 輕觸即可隱藏字幕 + 輕觸即可顯示字幕 + 倒轉 %d 秒 + 快轉 %d 秒 + 進入全螢幕模式 + 結束全螢幕模式 diff --git a/library/ui/src/main/res/values-zu/strings.xml b/library/ui/src/main/res/values-zu/strings.xml index f1f6207342..130cc35fd2 100644 --- a/library/ui/src/main/res/values-zu/strings.xml +++ b/library/ui/src/main/res/values-zu/strings.xml @@ -28,7 +28,6 @@ Phinda konke Ukushova kuvuliwe Ukushova kuvaliwe - Imodi yesikrini esigcwele Inqubo ye-VR Landa Ukulandwa @@ -54,4 +53,17 @@ CC %1$.2f Mbps %1$s, %2$s + Ithrekhi yomsindo + Isivinini sokudlala + Ivamile + Buyela emuva kuhlu lwenkinobho yangaphambilini + Bona izinkinobho eziningi + Inqubekela phambili yokudlala + Amasethingi + Thepha ukuze ufihle imibhalo engezansi + Thepha ukuze ubonise imibhalo engezansi + Hlehlisa ngamasekhondi angu-%d + Dlulisela phambili ngamasekhondi angu-%d + Faka isikrini esigcwele + Phuma kusikrini esigcwele From 12559bbc8d41d9154a08e4b34bc39fd0e66f3fa8 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 17 Jul 2020 13:46:04 +0100 Subject: [PATCH 0703/1052] DefaultAudioSink: Make PCM vs non-PCM code paths clearer This change replaces a lot of individual isInputPcm branching with a single, larger branch. PiperOrigin-RevId: 321763040 --- .../exoplayer2/audio/DefaultAudioSink.java | 155 ++++++++++-------- 1 file changed, 88 insertions(+), 67 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 1263a46ffe..791fdd20ae 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -470,14 +470,27 @@ public final class DefaultAudioSink implements AudioSink { public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { boolean isInputPcm = Util.isEncodingLinearPcm(inputFormat.encoding); - int sampleRate = inputFormat.sampleRate; - int channelCount = inputFormat.channelCount; - @C.Encoding int encoding = inputFormat.encoding; - boolean useFloatOutput = - enableFloatOutput && Util.isEncodingHighResolutionPcm(inputFormat.encoding); - AudioProcessor[] availableAudioProcessors = - useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; + + int inputPcmFrameSize; + @Nullable AudioProcessor[] availableAudioProcessors; + boolean canApplyPlaybackParameters; + + boolean useOffload; + @C.Encoding int outputEncoding; + int outputSampleRate; + int outputChannelCount; + int outputChannelConfig; + int outputPcmFrameSize; + if (isInputPcm) { + inputPcmFrameSize = Util.getPcmFrameSize(inputFormat.encoding, inputFormat.channelCount); + + boolean useFloatOutput = + enableFloatOutput && Util.isEncodingHighResolutionPcm(inputFormat.encoding); + availableAudioProcessors = + useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; + canApplyPlaybackParameters = !useFloatOutput; + trimmingAudioProcessor.setTrimFrameCount( inputFormat.encoderDelay, inputFormat.encoderPadding); @@ -492,7 +505,8 @@ public final class DefaultAudioSink implements AudioSink { channelMappingAudioProcessor.setChannelMap(outputChannels); AudioProcessor.AudioFormat outputFormat = - new AudioProcessor.AudioFormat(sampleRate, channelCount, encoding); + new AudioProcessor.AudioFormat( + inputFormat.sampleRate, inputFormat.channelCount, inputFormat.encoding); for (AudioProcessor audioProcessor : availableAudioProcessors) { try { AudioProcessor.AudioFormat nextFormat = audioProcessor.configure(outputFormat); @@ -503,43 +517,50 @@ public final class DefaultAudioSink implements AudioSink { throw new ConfigurationException(e); } } - sampleRate = outputFormat.sampleRate; - channelCount = outputFormat.channelCount; - encoding = outputFormat.encoding; + + useOffload = false; + outputEncoding = outputFormat.encoding; + outputSampleRate = outputFormat.sampleRate; + outputChannelCount = outputFormat.channelCount; + outputChannelConfig = Util.getAudioTrackChannelConfig(outputChannelCount); + outputPcmFrameSize = Util.getPcmFrameSize(outputEncoding, outputChannelCount); + } else { + // We're configuring for either passthrough or offload. + useOffload = + enableOffload + && isOffloadedPlaybackSupported( + inputFormat.channelCount, + inputFormat.sampleRate, + inputFormat.encoding, + audioAttributes, + inputFormat.encoderDelay, + inputFormat.encoderPadding); + inputPcmFrameSize = C.LENGTH_UNSET; + availableAudioProcessors = new AudioProcessor[0]; + canApplyPlaybackParameters = false; + outputEncoding = inputFormat.encoding; + outputSampleRate = inputFormat.sampleRate; + outputChannelCount = inputFormat.channelCount; + outputPcmFrameSize = C.LENGTH_UNSET; + outputChannelConfig = + useOffload + ? Util.getAudioTrackChannelConfig(outputChannelCount) + : getChannelConfigForPassthrough(inputFormat.channelCount); } - int outputChannelConfig = getChannelConfig(channelCount, isInputPcm); if (outputChannelConfig == AudioFormat.CHANNEL_INVALID) { - throw new ConfigurationException("Unsupported channel count: " + channelCount); + throw new ConfigurationException("Unsupported channel count: " + outputChannelCount); } - int inputPcmFrameSize = - isInputPcm - ? Util.getPcmFrameSize(inputFormat.encoding, inputFormat.channelCount) - : C.LENGTH_UNSET; - int outputPcmFrameSize = - isInputPcm ? Util.getPcmFrameSize(encoding, channelCount) : C.LENGTH_UNSET; - boolean canApplyPlaybackParameters = isInputPcm && !useFloatOutput; - boolean useOffload = - enableOffload - && !isInputPcm - && isOffloadedPlaybackSupported( - channelCount, - sampleRate, - encoding, - audioAttributes, - inputFormat.encoderDelay, - inputFormat.encoderPadding); - Configuration pendingConfiguration = new Configuration( isInputPcm, inputPcmFrameSize, inputFormat.sampleRate, outputPcmFrameSize, - sampleRate, + outputSampleRate, outputChannelConfig, - encoding, + outputEncoding, specifiedBufferSize, canApplyPlaybackParameters, availableAudioProcessors, @@ -900,7 +921,7 @@ public final class DefaultAudioSink implements AudioSink { private boolean drainToEndOfStream() throws WriteException { boolean audioProcessorNeedsEndOfStream = false; if (drainingAudioProcessorIndex == C.INDEX_UNSET) { - drainingAudioProcessorIndex = configuration.isInputPcm ? 0 : activeAudioProcessors.length; + drainingAudioProcessorIndex = 0; audioProcessorNeedsEndOfStream = true; } while (drainingAudioProcessorIndex < activeAudioProcessors.length) { @@ -1285,8 +1306,8 @@ public final class DefaultAudioSink implements AudioSink { if (Util.SDK_INT < 29) { return false; } - int channelMask = getChannelConfig(channelCount, /* isInputPcm= */ false); - AudioFormat audioFormat = getAudioFormat(sampleRateHz, channelMask, encoding); + int channelConfig = Util.getAudioTrackChannelConfig(channelCount); + AudioFormat audioFormat = getAudioFormat(sampleRateHz, channelConfig, encoding); if (!AudioManager.isOffloadedPlaybackSupported( audioFormat, audioAttributes.getAudioAttributesV21())) { return false; @@ -1310,32 +1331,6 @@ public final class DefaultAudioSink implements AudioSink { return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); } - @RequiresApi(29) - private final class StreamEventCallbackV29 extends AudioTrack.StreamEventCallback { - private final Handler handler; - - public StreamEventCallbackV29() { - handler = new Handler(); - } - - @Override - public void onDataRequest(AudioTrack track, int size) { - Assertions.checkState(track == DefaultAudioSink.this.audioTrack); - if (listener != null) { - listener.onOffloadBufferEmptying(); - } - } - - public void register(AudioTrack audioTrack) { - audioTrack.registerStreamEventCallback(handler::post, this); - } - - public void unregister(AudioTrack audioTrack) { - audioTrack.unregisterStreamEventCallback(this); - handler.removeCallbacksAndMessages(/* token= */ null); - } - } - private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. int channelConfig = AudioFormat.CHANNEL_OUT_MONO; @@ -1345,8 +1340,8 @@ public final class DefaultAudioSink implements AudioSink { MODE_STATIC, audioSessionId); } - private static int getChannelConfig(int channelCount, boolean isInputPcm) { - if (Util.SDK_INT <= 28 && !isInputPcm) { + private static int getChannelConfigForPassthrough(int channelCount) { + if (Util.SDK_INT <= 28) { // In passthrough mode the channel count used to configure the audio track doesn't affect how // the stream is handled, except that some devices do overly-strict channel configuration // checks. Therefore we override the channel count so that a known-working channel @@ -1358,9 +1353,9 @@ public final class DefaultAudioSink implements AudioSink { } } - // Workaround for Nexus Player not reporting support for mono passthrough. - // (See [Internal: b/34268671].) - if (Util.SDK_INT <= 26 && "fugu".equals(Util.DEVICE) && !isInputPcm && channelCount == 1) { + // Workaround for Nexus Player not reporting support for mono passthrough. See + // [Internal: b/34268671]. + if (Util.SDK_INT <= 26 && "fugu".equals(Util.DEVICE) && channelCount == 1) { channelCount = 2; } @@ -1514,6 +1509,32 @@ public final class DefaultAudioSink implements AudioSink { } } + @RequiresApi(29) + private final class StreamEventCallbackV29 extends AudioTrack.StreamEventCallback { + private final Handler handler; + + public StreamEventCallbackV29() { + handler = new Handler(); + } + + @Override + public void onDataRequest(AudioTrack track, int size) { + Assertions.checkState(track == DefaultAudioSink.this.audioTrack); + if (listener != null) { + listener.onOffloadBufferEmptying(); + } + } + + public void register(AudioTrack audioTrack) { + audioTrack.registerStreamEventCallback(handler::post, this); + } + + public void unregister(AudioTrack audioTrack) { + audioTrack.unregisterStreamEventCallback(this); + handler.removeCallbacksAndMessages(/* token= */ null); + } + } + /** Stores parameters used to calculate the current media position. */ private static final class MediaPositionParameters { From 307436534871b383741e8597f61a78a756f3ddfc Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 17 Jul 2020 15:25:03 +0100 Subject: [PATCH 0704/1052] Suppress deprecation warning in CastPlayer PiperOrigin-RevId: 321774583 --- .../com/google/android/exoplayer2/ext/cast/CastPlayer.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 51921fddc0..58ee5e983d 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -427,6 +427,9 @@ public final class CastPlayer extends BasePlayer { return playWhenReady.value; } + // We still call EventListener#onSeekProcessed() for backwards compatibility with listeners that + // don't implement onPositionDiscontinuity(). + @SuppressWarnings("deprecation") @Override public void seekTo(int windowIndex, long positionMs) { MediaStatus mediaStatus = getMediaStatus(); From 21f5914e56b9a801b98561a794c7b364807f8310 Mon Sep 17 00:00:00 2001 From: olly Date: Sun, 19 Jul 2020 10:53:26 +0100 Subject: [PATCH 0705/1052] Re-add rawtypes suppression PiperOrigin-RevId: 322008577 --- .../google/android/exoplayer2/source/dash/DashMediaPeriod.java | 2 +- .../exoplayer2/source/smoothstreaming/SsMediaPeriod.java | 2 +- .../android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 08b4959ebb..3d5f05268b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -895,7 +895,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } // We won't assign the array to a variable that erases the generic type, and then write into it. - @SuppressWarnings({"unchecked"}) + @SuppressWarnings({"unchecked", "rawtypes"}) private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java index 81f4a099e4..6fe999661c 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java @@ -283,7 +283,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } // We won't assign the array to a variable that erases the generic type, and then write into it. - @SuppressWarnings({"unchecked"}) + @SuppressWarnings({"unchecked", "rawtypes"}) private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java index 760a1958f0..d3eec0b85b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java @@ -184,7 +184,7 @@ public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod } // We won't assign the array to a variable that erases the generic type, and then write into it. - @SuppressWarnings({"unchecked"}) + @SuppressWarnings({"unchecked", "rawtypes"}) private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } From 9185b5177a3daab092696220b2a09ad3d1ae6fcd Mon Sep 17 00:00:00 2001 From: olly Date: Sun, 19 Jul 2020 11:11:51 +0100 Subject: [PATCH 0706/1052] Temporarily remove OPUS from sync samples optimization. This is currently causing frames to be dropped when we seek. PiperOrigin-RevId: 322009748 --- .../main/java/com/google/android/exoplayer2/util/MimeTypes.java | 1 - 1 file changed, 1 deletion(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index 1253f44883..10e8d19063 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -193,7 +193,6 @@ public final class MimeTypes { case AUDIO_RAW: case AUDIO_ALAW: case AUDIO_MLAW: - case AUDIO_OPUS: case AUDIO_FLAC: case AUDIO_AC3: case AUDIO_E_AC3: From a8b64f953075db91ddf1018be60681f08829540a Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 20 Jul 2020 09:09:50 +0100 Subject: [PATCH 0707/1052] Remove extra MediaItem in demo app PiperOrigin-RevId: 322094331 --- .../java/com/google/android/exoplayer2/demo/PlayerActivity.java | 1 - 1 file changed, 1 deletion(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 575b4bbce1..bf203159f9 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -394,7 +394,6 @@ public class PlayerActivity extends AppCompatActivity if (!hasAds) { releaseAdsLoader(); } - mediaItems.add(0, MediaItem.fromUri("https://html5demos.com/assets/dizzy.mp4")); return mediaItems; } From 7fce04a67f6cbbd11a6f441f47bb867d257817f0 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 20 Jul 2020 11:16:30 +0100 Subject: [PATCH 0708/1052] Depend on the IMA extension in noExtensions variant Also use the cronet extension in the demo app. PiperOrigin-RevId: 322108530 --- RELEASENOTES.md | 5 ++- demos/main/build.gradle | 25 +++++++------ demos/main/proguard-rules.txt | 5 --- .../exoplayer2/demo/DemoApplication.java | 32 ++++++++-------- .../exoplayer2/demo/PlayerActivity.java | 37 ++----------------- demos/main/src/main/res/values/strings.xml | 2 - extensions/ima/README.md | 10 ++--- 7 files changed, 41 insertions(+), 75 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 208997a1ae..cb7dbae348 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -250,7 +250,10 @@ * Migrate to new 'friendly obstruction' IMA SDK APIs, and allow apps to register a purpose and detail reason for overlay views via `AdsLoader.AdViewProvider`. -* Demo app: Retain previous position in list of samples. +* Demo app: + * Retain previous position in list of samples. + * Replace the `extensions` variant with `decoderExtensions` and make the + demo app use the Cronet and IMA extensions by default. * Add Guava dependency. ### 2.11.7 (2020-06-29) ### diff --git a/demos/main/build.gradle b/demos/main/build.gradle index f26fd7dc32..abf5a471b8 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -50,14 +50,14 @@ android { disable 'GoogleAppIndexingWarning','MissingTranslation','IconDensities' } - flavorDimensions "extensions" + flavorDimensions "decoderExtensions" productFlavors { - noExtensions { - dimension "extensions" + noDecoderExtensions { + dimension "decoderExtensions" } - withExtensions { - dimension "extensions" + withDecoderExtensions { + dimension "decoderExtensions" } } } @@ -72,13 +72,14 @@ dependencies { implementation project(modulePrefix + 'library-hls') implementation project(modulePrefix + 'library-smoothstreaming') implementation project(modulePrefix + 'library-ui') - withExtensionsImplementation project(path: modulePrefix + 'extension-av1') - withExtensionsImplementation project(path: modulePrefix + 'extension-ffmpeg') - withExtensionsImplementation project(path: modulePrefix + 'extension-flac') - withExtensionsImplementation project(path: modulePrefix + 'extension-ima') - withExtensionsImplementation project(path: modulePrefix + 'extension-opus') - withExtensionsImplementation project(path: modulePrefix + 'extension-vp9') - withExtensionsImplementation project(path: modulePrefix + 'extension-rtmp') + implementation project(modulePrefix + 'extension-cronet') + implementation project(modulePrefix + 'extension-ima') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-av1') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-ffmpeg') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-flac') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-opus') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-vp9') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-rtmp') } apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/demos/main/proguard-rules.txt b/demos/main/proguard-rules.txt index cd201892ab..5358f3cec7 100644 --- a/demos/main/proguard-rules.txt +++ b/demos/main/proguard-rules.txt @@ -1,7 +1,2 @@ # Proguard rules specific to the main demo app. -# Constructor accessed via reflection in PlayerActivity --dontnote com.google.android.exoplayer2.ext.ima.ImaAdsLoader --keepclassmembers class com.google.android.exoplayer2.ext.ima.ImaAdsLoader { - (android.content.Context, android.net.Uri); -} diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java index 39e64f8025..f4205efbb4 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java @@ -20,13 +20,14 @@ import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.ext.cronet.CronetDataSourceFactory; +import com.google.android.exoplayer2.ext.cronet.CronetEngineWrapper; import com.google.android.exoplayer2.offline.ActionFileUpgradeUtil; import com.google.android.exoplayer2.offline.DefaultDownloadIndex; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.ui.DownloadNotificationHelper; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; @@ -50,8 +51,7 @@ public class DemoApplication extends MultiDexApplication { private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; - protected String userAgent; - + private HttpDataSource.Factory httpDataSourceFactory; private DatabaseProvider databaseProvider; private File downloadDirectory; private Cache downloadCache; @@ -62,24 +62,26 @@ public class DemoApplication extends MultiDexApplication { @Override public void onCreate() { super.onCreate(); - userAgent = Util.getUserAgent(this, "ExoPlayerDemo"); + CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(/* context= */ this); + String userAgent = Util.getUserAgent(this, "ExoPlayerDemo"); + httpDataSourceFactory = + new CronetDataSourceFactory( + cronetEngineWrapper, + Executors.newSingleThreadExecutor(), + /* transferListener= */ null, + userAgent); } /** Returns a {@link DataSource.Factory}. */ public DataSource.Factory buildDataSourceFactory() { DefaultDataSourceFactory upstreamFactory = - new DefaultDataSourceFactory(this, buildHttpDataSourceFactory()); + new DefaultDataSourceFactory(/* context= */ this, httpDataSourceFactory); return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache()); } - /** Returns a {@link HttpDataSource.Factory}. */ - public HttpDataSource.Factory buildHttpDataSourceFactory() { - return new DefaultHttpDataSourceFactory(userAgent); - } - /** Returns whether extension renderers should be used. */ public boolean useExtensionRenderers() { - return "withExtensions".equals(BuildConfig.FLAVOR); + return "withDecoderExtensions".equals(BuildConfig.FLAVOR); } public RenderersFactory buildRenderersFactory(boolean preferExtensionRenderer) { @@ -112,7 +114,7 @@ public class DemoApplication extends MultiDexApplication { return downloadTracker; } - protected synchronized Cache getDownloadCache() { + private synchronized Cache getDownloadCache() { if (downloadCache == null) { File downloadContentDirectory = new File(getDownloadDirectory(), DOWNLOAD_CONTENT_DIRECTORY); downloadCache = @@ -130,10 +132,10 @@ public class DemoApplication extends MultiDexApplication { DOWNLOAD_TRACKER_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ true); downloadManager = new DownloadManager( - this, + /* context= */ this, getDatabaseProvider(), getDownloadCache(), - buildHttpDataSourceFactory(), + httpDataSourceFactory, Executors.newFixedThreadPool(/* nThreads= */ 6)); downloadTracker = new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadManager); @@ -171,7 +173,7 @@ public class DemoApplication extends MultiDexApplication { return downloadDirectory; } - protected static CacheDataSource.Factory buildReadOnlyCacheDataSource( + private static CacheDataSource.Factory buildReadOnlyCacheDataSource( DataSource.Factory upstreamFactory, Cache cache) { return new CacheDataSource.Factory() .setCache(cache) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index bf203159f9..5983f41255 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -29,7 +29,6 @@ import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -39,6 +38,7 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; import com.google.android.exoplayer2.source.BehindLiveWindowException; @@ -60,7 +60,6 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ErrorMessageProvider; import com.google.android.exoplayer2.util.EventLogger; import com.google.android.exoplayer2.util.Util; -import java.lang.reflect.Constructor; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; @@ -447,31 +446,6 @@ public class PlayerActivity extends AppCompatActivity return ((DemoApplication) getApplication()).buildDataSourceFactory(); } - /** - * Returns an ads loader for the Interactive Media Ads SDK if found in the classpath, or null - * otherwise. - */ - @Nullable - private AdsLoader maybeCreateAdsLoader(Uri adTagUri) { - // Load the extension source using reflection so the demo app doesn't have to depend on it. - try { - Class loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader"); - // Full class names used so the lint rule triggers should any of the classes move. - // LINT.IfChange - Constructor loaderConstructor = - loaderClass - .asSubclass(AdsLoader.class) - .getConstructor(android.content.Context.class, android.net.Uri.class); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - return loaderConstructor.newInstance(this, adTagUri); - } catch (ClassNotFoundException e) { - // IMA extension not loaded. - return null; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - // User controls private void updateButtonVisibility() { @@ -585,7 +559,6 @@ public class PlayerActivity extends AppCompatActivity private class AdSupportProvider implements DefaultMediaSourceFactory.AdSupportProvider { - @Nullable @Override public AdsLoader getAdsLoader(Uri adTagUri) { if (mediaItems.size() > 1) { @@ -599,13 +572,9 @@ public class PlayerActivity extends AppCompatActivity } // The ads loader is reused for multiple playbacks, so that ad playback can resume. if (adsLoader == null) { - adsLoader = maybeCreateAdsLoader(adTagUri); - } - if (adsLoader != null) { - adsLoader.setPlayer(player); - } else { - showToast(R.string.ima_not_loaded); + adsLoader = new ImaAdsLoader(/* context= */ PlayerActivity.this, adTagUri); } + adsLoader.setPlayer(player); return adsLoader; } diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index 671303a522..fab74d03cc 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -51,8 +51,6 @@ One or more sample lists failed to load - Playing sample without ads, as the IMA extension was not loaded - Playing sample without ads, as ads are not supported in concatenations Failed to start download diff --git a/extensions/ima/README.md b/extensions/ima/README.md index f28ba2977e..0a9bf1aa5e 100644 --- a/extensions/ima/README.md +++ b/extensions/ima/README.md @@ -46,12 +46,10 @@ On returning to the foreground, seek to that position before preparing the new player instance. Finally, it is important to call `ImaAdsLoader.release()` when playback of the content/ads has finished and will not be resumed. -You can try the IMA extension in the ExoPlayer demo app. To do this you must -select and build one of the `withExtensions` build variants of the demo app in -Android Studio. You can find IMA test content in the "IMA sample ad tags" -section of the app. The demo app's `PlayerActivity` also shows how to persist -the `ImaAdsLoader` instance and the player position when backgrounded during ad -playback. +You can try the IMA extension in the ExoPlayer demo app, which has test content +in the "IMA sample ad tags" section of the sample chooser. The demo app's +`PlayerActivity` also shows how to persist the `ImaAdsLoader` instance and the +player position when backgrounded during ad playback. [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md [sample ad tags]: https://developers.google.com/interactive-media-ads/docs/sdks/android/tags From 63b965d3f0d19f6e9780b3c9b313b93471b69219 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 20 Jul 2020 12:10:51 +0100 Subject: [PATCH 0709/1052] Non-null MIME types infer to other content type PiperOrigin-RevId: 322114754 --- .../src/main/java/com/google/android/exoplayer2/util/Util.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index afb97eb557..abd71278cd 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -1736,7 +1736,7 @@ public final class Util { case MimeTypes.APPLICATION_SS: return C.TYPE_SS; default: - return Util.inferContentType(uri); + return C.TYPE_OTHER; } } From c669756f7d6222642f9f9479ab866babed46e4b2 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 20 Jul 2020 12:16:03 +0100 Subject: [PATCH 0710/1052] Add a missing deprecation suppression in CastPlayer PiperOrigin-RevId: 322115322 --- .../com/google/android/exoplayer2/ext/cast/CastPlayer.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 58ee5e983d..957001fe74 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -1095,6 +1095,9 @@ public final class CastPlayer extends BasePlayer { private final class SeekResultCallback implements ResultCallback { + // We still call EventListener#onSeekProcessed() for backwards compatibility with listeners that + // don't implement onPositionDiscontinuity(). + @SuppressWarnings("deprecation") @Override public void onResult(MediaChannelResult result) { int statusCode = result.getStatus().getStatusCode(); From b249480060e345bdebc5f7d5168ba18f22064e77 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 20 Jul 2020 12:33:28 +0100 Subject: [PATCH 0711/1052] Offline: store MIME type and keySetId Replace `type` with (optional) `mimeType` and add `keySetId` in DownloadRequest. The DownloadHelper infers the downloading method (DASH, HLS, SmoothStreaming or Progressive) from the content's MIME type and URI. PiperOrigin-RevId: 322117384 --- .../google/android/exoplayer2/util/Util.java | 10 + .../android/exoplayer2/util/UtilTest.java | 17 ++ .../exoplayer2/database/VersionTable.java | 15 +- .../exoplayer2/offline/ActionFile.java | 39 +++- .../offline/DefaultDownloadIndex.java | 199 ++++++++++++++---- .../offline/DefaultDownloaderFactory.java | 105 +++++---- .../exoplayer2/offline/DownloadHelper.java | 52 ++--- .../exoplayer2/offline/DownloadRequest.java | 74 ++++--- .../exoplayer2/database/VersionTableTest.java | 12 -- .../exoplayer2/offline/ActionFileTest.java | 4 +- .../offline/ActionFileUpgradeUtilTest.java | 58 ++--- .../offline/DefaultDownloadIndexTest.java | 103 +++++++-- .../offline/DefaultDownloaderFactoryTest.java | 5 +- .../offline/DownloadHelperTest.java | 2 +- .../offline/DownloadManagerTest.java | 3 +- .../offline/DownloadRequestTest.java | 86 ++++---- .../dash/offline/DashDownloaderTest.java | 4 +- .../dash/offline/DownloadManagerDashTest.java | 4 +- .../dash/offline/DownloadServiceDashTest.java | 4 +- .../source/hls/offline/HlsDownloaderTest.java | 4 +- .../offline/SsDownloaderTest.java | 4 +- .../assets/offline/exoplayer_internal_v2.db | Bin 0 -> 40960 bytes .../exoplayer2/testutil/DownloadBuilder.java | 39 ++-- 23 files changed, 534 insertions(+), 309 deletions(-) create mode 100644 testdata/src/test/assets/offline/exoplayer_internal_v2.db diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index abd71278cd..1a5cec63d5 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -30,6 +30,8 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Configuration; import android.content.res.Resources; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; import android.graphics.Point; import android.media.AudioFormat; import android.net.ConnectivityManager; @@ -2227,6 +2229,14 @@ public final class Util { items.addAll(Math.min(newFromIndex, items.size()), removedItems); } + /** Returns whether the table exists in the database. */ + public static boolean tableExists(SQLiteDatabase database, String tableName) { + long count = + DatabaseUtils.queryNumEntries( + database, "sqlite_master", "tbl_name = ?", new String[] {tableName}); + return count > 0; + } + @Nullable private static String getSystemProperty(String name) { try { diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java index d3294997da..b6e3ae0f41 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.util; +import static com.google.android.exoplayer2.testutil.TestUtil.getInMemoryDatabaseProvider; import static com.google.android.exoplayer2.util.Util.binarySearchCeil; import static com.google.android.exoplayer2.util.Util.binarySearchFloor; import static com.google.android.exoplayer2.util.Util.escapeFileName; @@ -24,6 +25,7 @@ import static com.google.android.exoplayer2.util.Util.parseXsDuration; import static com.google.android.exoplayer2.util.Util.unescapeFileName; import static com.google.common.truth.Truth.assertThat; +import android.database.sqlite.SQLiteDatabase; import android.text.SpannableString; import android.text.Spanned; import android.text.style.StrikethroughSpan; @@ -997,6 +999,21 @@ public class UtilTest { assertThat(Util.normalizeLanguageCode("hsn")).isEqualTo("zh-hsn"); } + @Test + public void tableExists_withExistingTable() { + SQLiteDatabase database = getInMemoryDatabaseProvider().getWritableDatabase(); + database.execSQL("CREATE TABLE TestTable (ID INTEGER NOT NULL)"); + + assertThat(Util.tableExists(database, "TestTable")).isTrue(); + } + + @Test + public void tableExists_withNonExistingTable() { + SQLiteDatabase database = getInMemoryDatabaseProvider().getReadableDatabase(); + + assertThat(Util.tableExists(database, "table")).isFalse(); + } + private static void assertEscapeUnescapeFileName(String fileName, String escapedFileName) { assertThat(escapeFileName(fileName)).isEqualTo(escapedFileName); assertThat(unescapeFileName(escapedFileName)).isEqualTo(fileName); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java index f1d269ddbf..e69b9576dd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java @@ -17,11 +17,10 @@ package com.google.android.exoplayer2.database; import android.content.ContentValues; import android.database.Cursor; -import android.database.DatabaseUtils; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import androidx.annotation.IntDef; -import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -115,7 +114,7 @@ public final class VersionTable { SQLiteDatabase writableDatabase, @Feature int feature, String instanceUid) throws DatabaseIOException { try { - if (!tableExists(writableDatabase, TABLE_NAME)) { + if (!Util.tableExists(writableDatabase, TABLE_NAME)) { return; } writableDatabase.delete( @@ -140,7 +139,7 @@ public final class VersionTable { public static int getVersion(SQLiteDatabase database, @Feature int feature, String instanceUid) throws DatabaseIOException { try { - if (!tableExists(database, TABLE_NAME)) { + if (!Util.tableExists(database, TABLE_NAME)) { return VERSION_UNSET; } try (Cursor cursor = @@ -163,14 +162,6 @@ public final class VersionTable { } } - @VisibleForTesting - /* package */ static boolean tableExists(SQLiteDatabase readableDatabase, String tableName) { - long count = - DatabaseUtils.queryNumEntries( - readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName}); - return count > 0; - } - private static String[] featureAndInstanceUidArguments(int feature, String instance) { return new String[] {Integer.toString(feature), instance}; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java index c69908c746..0807241b1e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java @@ -19,6 +19,7 @@ import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.offline.DownloadRequest.UnsupportedRequestException; import com.google.android.exoplayer2.util.AtomicFile; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.DataInputStream; import java.io.File; @@ -37,6 +38,10 @@ import java.util.List; /* package */ final class ActionFile { private static final int VERSION = 0; + private static final String DOWNLOAD_TYPE_PROGRESSIVE = "progressive"; + private static final String DOWNLOAD_TYPE_DASH = "dash"; + private static final String DOWNLOAD_TYPE_HLS = "hls"; + private static final String DOWNLOAD_TYPE_SS = "ss"; private final AtomicFile atomicFile; @@ -92,7 +97,7 @@ import java.util.List; } private static DownloadRequest readDownloadRequest(DataInputStream input) throws IOException { - String type = input.readUTF(); + String downloadType = input.readUTF(); int version = input.readInt(); Uri uri = Uri.parse(input.readUTF()); @@ -108,21 +113,21 @@ import java.util.List; } // Serialized version 0 progressive actions did not contain keys. - boolean isLegacyProgressive = version == 0 && DownloadRequest.TYPE_PROGRESSIVE.equals(type); + boolean isLegacyProgressive = version == 0 && DOWNLOAD_TYPE_PROGRESSIVE.equals(downloadType); List keys = new ArrayList<>(); if (!isLegacyProgressive) { int keyCount = input.readInt(); for (int i = 0; i < keyCount; i++) { - keys.add(readKey(type, version, input)); + keys.add(readKey(downloadType, version, input)); } } // Serialized version 0 and 1 DASH/HLS/SS actions did not contain a custom cache key. boolean isLegacySegmented = version < 2 - && (DownloadRequest.TYPE_DASH.equals(type) - || DownloadRequest.TYPE_HLS.equals(type) - || DownloadRequest.TYPE_SS.equals(type)); + && (DOWNLOAD_TYPE_DASH.equals(downloadType) + || DOWNLOAD_TYPE_HLS.equals(downloadType) + || DOWNLOAD_TYPE_SS.equals(downloadType)); @Nullable String customCacheKey = null; if (!isLegacySegmented) { customCacheKey = input.readBoolean() ? input.readUTF() : null; @@ -135,7 +140,10 @@ import java.util.List; // Remove actions are not supported anymore. throw new UnsupportedRequestException(); } - return new DownloadRequest(id, type, uri, keys, customCacheKey, data); + // keySetId and mimeType were not supported. Set keySetId to null and try to infer the mime + // type from the download type. + return new DownloadRequest( + id, uri, inferMimeType(downloadType), keys, /* keySetId= */ null, customCacheKey, data); } private static StreamKey readKey(String type, int version, DataInputStream input) @@ -145,8 +153,7 @@ import java.util.List; int trackIndex; // Serialized version 0 HLS/SS actions did not contain a period index. - if ((DownloadRequest.TYPE_HLS.equals(type) || DownloadRequest.TYPE_SS.equals(type)) - && version == 0) { + if ((DOWNLOAD_TYPE_HLS.equals(type) || DOWNLOAD_TYPE_SS.equals(type)) && version == 0) { periodIndex = 0; groupIndex = input.readInt(); trackIndex = input.readInt(); @@ -158,6 +165,20 @@ import java.util.List; return new StreamKey(periodIndex, groupIndex, trackIndex); } + private static String inferMimeType(String downloadType) { + switch (downloadType) { + case DOWNLOAD_TYPE_DASH: + return MimeTypes.APPLICATION_MPD; + case DOWNLOAD_TYPE_HLS: + return MimeTypes.APPLICATION_M3U8; + case DOWNLOAD_TYPE_SS: + return MimeTypes.APPLICATION_SS; + case DOWNLOAD_TYPE_PROGRESSIVE: + default: + return MimeTypes.VIDEO_UNKNOWN; + } + } + private static String generateDownloadId(Uri uri, @Nullable String customCacheKey) { return customCacheKey != null ? customCacheKey : uri.toString(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java index 4437fccd16..90bf57cd47 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.database.VersionTable; import com.google.android.exoplayer2.offline.Download.FailureReason; import com.google.android.exoplayer2.offline.Download.State; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; import java.util.List; @@ -38,10 +39,10 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "Downloads"; - @VisibleForTesting /* package */ static final int TABLE_VERSION = 2; + @VisibleForTesting /* package */ static final int TABLE_VERSION = 3; private static final String COLUMN_ID = "id"; - private static final String COLUMN_TYPE = "title"; + private static final String COLUMN_MIME_TYPE = "mime_type"; private static final String COLUMN_URI = "uri"; private static final String COLUMN_STREAM_KEYS = "stream_keys"; private static final String COLUMN_CUSTOM_CACHE_KEY = "custom_cache_key"; @@ -54,9 +55,10 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final String COLUMN_FAILURE_REASON = "failure_reason"; private static final String COLUMN_PERCENT_DOWNLOADED = "percent_downloaded"; private static final String COLUMN_BYTES_DOWNLOADED = "bytes_downloaded"; + private static final String COLUMN_KEY_SET_ID = "key_set_id"; private static final int COLUMN_INDEX_ID = 0; - private static final int COLUMN_INDEX_TYPE = 1; + private static final int COLUMN_INDEX_MIME_TYPE = 1; private static final int COLUMN_INDEX_URI = 2; private static final int COLUMN_INDEX_STREAM_KEYS = 3; private static final int COLUMN_INDEX_CUSTOM_CACHE_KEY = 4; @@ -69,6 +71,7 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final int COLUMN_INDEX_FAILURE_REASON = 11; private static final int COLUMN_INDEX_PERCENT_DOWNLOADED = 12; private static final int COLUMN_INDEX_BYTES_DOWNLOADED = 13; + private static final int COLUMN_INDEX_KEY_SET_ID = 14; private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?"; private static final String WHERE_STATE_IS_DOWNLOADING = @@ -79,7 +82,7 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final String[] COLUMNS = new String[] { COLUMN_ID, - COLUMN_TYPE, + COLUMN_MIME_TYPE, COLUMN_URI, COLUMN_STREAM_KEYS, COLUMN_CUSTOM_CACHE_KEY, @@ -92,14 +95,15 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { COLUMN_FAILURE_REASON, COLUMN_PERCENT_DOWNLOADED, COLUMN_BYTES_DOWNLOADED, + COLUMN_KEY_SET_ID }; private static final String TABLE_SCHEMA = "(" + COLUMN_ID + " TEXT PRIMARY KEY NOT NULL," - + COLUMN_TYPE - + " TEXT NOT NULL," + + COLUMN_MIME_TYPE + + " TEXT," + COLUMN_URI + " TEXT NOT NULL," + COLUMN_STREAM_KEYS @@ -123,7 +127,9 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { + COLUMN_PERCENT_DOWNLOADED + " REAL NOT NULL," + COLUMN_BYTES_DOWNLOADED - + " INTEGER NOT NULL)"; + + " INTEGER NOT NULL," + + COLUMN_KEY_SET_ID + + " BLOB NOT NULL)"; private static final String TRUE = "1"; @@ -189,24 +195,9 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { @Override public void putDownload(Download download) throws DatabaseIOException { ensureInitialized(); - ContentValues values = new ContentValues(); - values.put(COLUMN_ID, download.request.id); - values.put(COLUMN_TYPE, download.request.type); - values.put(COLUMN_URI, download.request.uri.toString()); - values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(download.request.streamKeys)); - values.put(COLUMN_CUSTOM_CACHE_KEY, download.request.customCacheKey); - values.put(COLUMN_DATA, download.request.data); - values.put(COLUMN_STATE, download.state); - values.put(COLUMN_START_TIME_MS, download.startTimeMs); - values.put(COLUMN_UPDATE_TIME_MS, download.updateTimeMs); - values.put(COLUMN_CONTENT_LENGTH, download.contentLength); - values.put(COLUMN_STOP_REASON, download.stopReason); - values.put(COLUMN_FAILURE_REASON, download.failureReason); - values.put(COLUMN_PERCENT_DOWNLOADED, download.getPercentDownloaded()); - values.put(COLUMN_BYTES_DOWNLOADED, download.getBytesDownloaded()); try { SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); - writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + putDownloadInternal(download, writableDatabase); } catch (SQLiteException e) { throw new DatabaseIOException(e); } @@ -294,8 +285,13 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { try { VersionTable.setVersion( writableDatabase, VersionTable.FEATURE_OFFLINE, name, TABLE_VERSION); + List upgradedDownloads = + version == 2 ? loadDownloadsFromVersion2(writableDatabase) : new ArrayList<>(); writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName); writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA); + for (Download download : upgradedDownloads) { + putDownloadInternal(download, writableDatabase); + } writableDatabase.setTransactionSuccessful(); } finally { writableDatabase.endTransaction(); @@ -307,6 +303,78 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { } } + private void putDownloadInternal(Download download, SQLiteDatabase database) { + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, download.request.id); + values.put(COLUMN_MIME_TYPE, download.request.mimeType); + values.put(COLUMN_URI, download.request.uri.toString()); + values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(download.request.streamKeys)); + values.put(COLUMN_CUSTOM_CACHE_KEY, download.request.customCacheKey); + values.put(COLUMN_DATA, download.request.data); + values.put(COLUMN_STATE, download.state); + values.put(COLUMN_START_TIME_MS, download.startTimeMs); + values.put(COLUMN_UPDATE_TIME_MS, download.updateTimeMs); + values.put(COLUMN_CONTENT_LENGTH, download.contentLength); + values.put(COLUMN_STOP_REASON, download.stopReason); + values.put(COLUMN_FAILURE_REASON, download.failureReason); + values.put(COLUMN_PERCENT_DOWNLOADED, download.getPercentDownloaded()); + values.put(COLUMN_BYTES_DOWNLOADED, download.getBytesDownloaded()); + values.put(COLUMN_KEY_SET_ID, download.request.keySetId); + database.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + } + + private List loadDownloadsFromVersion2(SQLiteDatabase database) { + List downloads = new ArrayList<>(); + if (!Util.tableExists(database, tableName)) { + return downloads; + } + + String[] columnsV2 = + new String[] { + "id", + "title", + "uri", + "stream_keys", + "custom_cache_key", + "data", + "state", + "start_time_ms", + "update_time_ms", + "content_length", + "stop_reason", + "failure_reason", + "percent_downloaded", + "bytes_downloaded" + }; + try (Cursor cursor = + database.query( + tableName, + columnsV2, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null); ) { + while (cursor.moveToNext()) { + downloads.add(getDownloadForCurrentRowV2(cursor)); + } + return downloads; + } + } + + /** Infers the MIME type from a v2 table row. */ + private static String inferMimeType(String downloadType) { + if ("dash".equals(downloadType)) { + return MimeTypes.APPLICATION_MPD; + } else if ("hls".equals(downloadType)) { + return MimeTypes.APPLICATION_M3U8; + } else if ("ss".equals(downloadType)) { + return MimeTypes.APPLICATION_SS; + } else { + return MimeTypes.VIDEO_UNKNOWN; + } + } + private Cursor getCursor(String selection, @Nullable String[] selectionArgs) throws DatabaseIOException { try { @@ -326,6 +394,25 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { } } + @VisibleForTesting + /* package*/ static String encodeStreamKeys(List streamKeys) { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < streamKeys.size(); i++) { + StreamKey streamKey = streamKeys.get(i); + stringBuilder + .append(streamKey.periodIndex) + .append('.') + .append(streamKey.groupIndex) + .append('.') + .append(streamKey.trackIndex) + .append(','); + } + if (stringBuilder.length() > 0) { + stringBuilder.setLength(stringBuilder.length() - 1); + } + return stringBuilder.toString(); + } + private static String getStateQuery(@Download.State int... states) { if (states.length == 0) { return TRUE; @@ -346,9 +433,10 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { DownloadRequest request = new DownloadRequest( /* id= */ cursor.getString(COLUMN_INDEX_ID), - /* type= */ cursor.getString(COLUMN_INDEX_TYPE), /* uri= */ Uri.parse(cursor.getString(COLUMN_INDEX_URI)), + /* mimeType= */ cursor.getString(COLUMN_INDEX_MIME_TYPE), /* streamKeys= */ decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)), + /* keySetId= */ cursor.getBlob(COLUMN_INDEX_KEY_SET_ID), /* customCacheKey= */ cursor.getString(COLUMN_INDEX_CUSTOM_CACHE_KEY), /* data= */ cursor.getBlob(COLUMN_INDEX_DATA)); DownloadProgress downloadProgress = new DownloadProgress(); @@ -373,22 +461,53 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { downloadProgress); } - private static String encodeStreamKeys(List streamKeys) { - StringBuilder stringBuilder = new StringBuilder(); - for (int i = 0; i < streamKeys.size(); i++) { - StreamKey streamKey = streamKeys.get(i); - stringBuilder - .append(streamKey.periodIndex) - .append('.') - .append(streamKey.groupIndex) - .append('.') - .append(streamKey.trackIndex) - .append(','); - } - if (stringBuilder.length() > 0) { - stringBuilder.setLength(stringBuilder.length() - 1); - } - return stringBuilder.toString(); + /** Read a {@link Download} from a table row of version 2. */ + private static Download getDownloadForCurrentRowV2(Cursor cursor) { + /* + * Version 2 schema + * Index Column Type + * 0 id string + * 1 type string + * 2 uri string + * 3 stream_keys string + * 4 custom_cache_key string + * 5 data blob + * 6 state integer + * 7 start_time_ms integer + * 8 update_time_ms integer + * 9 content_length integer + * 10 stop_reason integer + * 11 failure_reason integer + * 12 percent_downloaded real + * 13 bytes_downloaded integer + */ + DownloadRequest request = + new DownloadRequest( + /* id= */ cursor.getString(0), + /* uri= */ Uri.parse(cursor.getString(2)), + /* mimeType= */ inferMimeType(cursor.getString(1)), + /* streamKeys= */ decodeStreamKeys(cursor.getString(3)), + /* keySetId= */ null, + /* customCacheKey= */ cursor.getString(4), + /* data= */ cursor.getBlob(5)); + DownloadProgress downloadProgress = new DownloadProgress(); + downloadProgress.bytesDownloaded = cursor.getLong(13); + downloadProgress.percentDownloaded = cursor.getFloat(12); + @State int state = cursor.getInt(6); + // It's possible the database contains failure reasons for non-failed downloads, which is + // invalid. Clear them here. See https://github.com/google/ExoPlayer/issues/6785. + @FailureReason + int failureReason = + state == Download.STATE_FAILED ? cursor.getInt(11) : Download.FAILURE_REASON_NONE; + return new Download( + request, + state, + /* startTimeMs= */ cursor.getLong(7), + /* updateTimeMs= */ cursor.getLong(8), + /* contentLength= */ cursor.getLong(9), + /* stopReason= */ cursor.getInt(10), + failureReason, + downloadProgress); } private static List decodeStreamKeys(String encodedStreamKeys) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java index 183d214759..786d60b545 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -16,10 +16,13 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; +import android.util.SparseArray; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; import java.lang.reflect.Constructor; import java.util.List; import java.util.concurrent.Executor; @@ -31,46 +34,8 @@ import java.util.concurrent.Executor; */ public class DefaultDownloaderFactory implements DownloaderFactory { - @Nullable private static final Constructor DASH_DOWNLOADER_CONSTRUCTOR; - @Nullable private static final Constructor HLS_DOWNLOADER_CONSTRUCTOR; - @Nullable private static final Constructor SS_DOWNLOADER_CONSTRUCTOR; - - static { - @Nullable Constructor dashDownloaderConstructor = null; - try { - // LINT.IfChange - dashDownloaderConstructor = - getDownloaderConstructor( - Class.forName("com.google.android.exoplayer2.source.dash.offline.DashDownloader")); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the DASH module. - } - DASH_DOWNLOADER_CONSTRUCTOR = dashDownloaderConstructor; - @Nullable Constructor hlsDownloaderConstructor = null; - try { - // LINT.IfChange - hlsDownloaderConstructor = - getDownloaderConstructor( - Class.forName("com.google.android.exoplayer2.source.hls.offline.HlsDownloader")); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the HLS module. - } - HLS_DOWNLOADER_CONSTRUCTOR = hlsDownloaderConstructor; - @Nullable Constructor ssDownloaderConstructor = null; - try { - // LINT.IfChange - ssDownloaderConstructor = - getDownloaderConstructor( - Class.forName( - "com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader")); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the SmoothStreaming module. - } - SS_DOWNLOADER_CONSTRUCTOR = ssDownloaderConstructor; - } + private static final SparseArray> CONSTRUCTORS = + createDownloaderConstructors(); private final CacheDataSource.Factory cacheDataSourceFactory; private final Executor executor; @@ -105,8 +70,14 @@ public class DefaultDownloaderFactory implements DownloaderFactory { @Override public Downloader createDownloader(DownloadRequest request) { - switch (request.type) { - case DownloadRequest.TYPE_PROGRESSIVE: + @C.ContentType + int contentType = Util.inferContentTypeWithMimeType(request.uri, request.mimeType); + switch (contentType) { + case C.TYPE_DASH: + case C.TYPE_HLS: + case C.TYPE_SS: + return createDownloader(request, contentType); + case C.TYPE_OTHER: return new ProgressiveDownloader( new MediaItem.Builder() .setUri(request.uri) @@ -114,31 +85,57 @@ public class DefaultDownloaderFactory implements DownloaderFactory { .build(), cacheDataSourceFactory, executor); - case DownloadRequest.TYPE_DASH: - return createDownloader(request, DASH_DOWNLOADER_CONSTRUCTOR); - case DownloadRequest.TYPE_HLS: - return createDownloader(request, HLS_DOWNLOADER_CONSTRUCTOR); - case DownloadRequest.TYPE_SS: - return createDownloader(request, SS_DOWNLOADER_CONSTRUCTOR); default: - throw new IllegalArgumentException("Unsupported type: " + request.type); + throw new IllegalArgumentException("Unsupported type: " + contentType); } } - private Downloader createDownloader( - DownloadRequest request, @Nullable Constructor constructor) { + private Downloader createDownloader(DownloadRequest request, @C.ContentType int contentType) { + @Nullable Constructor constructor = CONSTRUCTORS.get(contentType); if (constructor == null) { - throw new IllegalStateException("Module missing for: " + request.type); + throw new IllegalStateException("Module missing for content type " + contentType); } try { return constructor.newInstance( request.uri, request.streamKeys, cacheDataSourceFactory, executor); } catch (Exception e) { - throw new RuntimeException("Failed to instantiate downloader for: " + request.type, e); + throw new IllegalStateException( + "Failed to instantiate downloader for content type " + contentType); } } // LINT.IfChange + private static SparseArray> createDownloaderConstructors() { + SparseArray> array = new SparseArray<>(); + try { + array.put( + C.TYPE_DASH, + getDownloaderConstructor( + Class.forName("com.google.android.exoplayer2.source.dash.offline.DashDownloader"))); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the DASH module. + } + + try { + array.put( + C.TYPE_HLS, + getDownloaderConstructor( + Class.forName("com.google.android.exoplayer2.source.hls.offline.HlsDownloader"))); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the HLS module. + } + try { + array.put( + C.TYPE_SS, + getDownloaderConstructor( + Class.forName( + "com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader"))); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the SmoothStreaming module. + } + return array; + } + private static Constructor getDownloaderConstructor(Class clazz) { try { return clazz @@ -146,7 +143,7 @@ public class DefaultDownloaderFactory implements DownloaderFactory { .getConstructor(Uri.class, List.class, CacheDataSource.Factory.class, Executor.class); } catch (NoSuchMethodException e) { // The downloader is present, but the expected constructor is missing. - throw new RuntimeException("Downloader constructor missing", e); + throw new IllegalStateException("Downloader constructor missing", e); } } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index d9d57b18f7..cda674a7bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -320,9 +320,7 @@ public final class DownloadHelper { * @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming. */ public static DownloadHelper forMediaItem(Context context, MediaItem mediaItem) { - Assertions.checkArgument( - DownloadRequest.TYPE_PROGRESSIVE.equals( - getDownloadType(checkNotNull(mediaItem.playbackProperties)))); + Assertions.checkArgument(isProgressive(checkNotNull(mediaItem.playbackProperties))); return forMediaItem( mediaItem, getDefaultTrackSelectorParameters(context), @@ -412,9 +410,7 @@ public final class DownloadHelper { @Nullable RenderersFactory renderersFactory, @Nullable DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { - boolean isProgressive = - DownloadRequest.TYPE_PROGRESSIVE.equals( - getDownloadType(checkNotNull(mediaItem.playbackProperties))); + boolean isProgressive = isProgressive(checkNotNull(mediaItem.playbackProperties)); Assertions.checkArgument(isProgressive || dataSourceFactory != null); return new DownloadHelper( mediaItem, @@ -455,7 +451,7 @@ public final class DownloadHelper { new MediaItem.Builder() .setUri(downloadRequest.uri) .setCustomCacheKey(downloadRequest.customCacheKey) - .setMimeType(getMimeType(downloadRequest.type)) + .setMimeType(downloadRequest.mimeType) .setStreamKeys(downloadRequest.streamKeys) .build(), dataSourceFactory, @@ -747,13 +743,14 @@ public final class DownloadHelper { * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { - String downloadType = getDownloadType(playbackProperties); if (mediaSource == null) { + // TODO: add support for DRM (keySetId) [Internal ref: b/158980798] return new DownloadRequest( id, - downloadType, playbackProperties.uri, + playbackProperties.mimeType, /* streamKeys= */ Collections.emptyList(), + /* keySetId= */ null, playbackProperties.customCacheKey, data); } @@ -769,11 +766,13 @@ public final class DownloadHelper { } streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); } + // TODO: add support for DRM (keySetId) [Internal ref: b/158980798] return new DownloadRequest( id, - downloadType, playbackProperties.uri, + playbackProperties.mimeType, streamKeys, + /* keySetId= */ null, playbackProperties.customCacheKey, data); } @@ -909,36 +908,9 @@ public final class DownloadHelper { .createMediaSource(mediaItem); } - private static String getDownloadType(MediaItem.PlaybackProperties playbackProperties) { - int contentType = - Util.inferContentTypeWithMimeType(playbackProperties.uri, playbackProperties.mimeType); - switch (contentType) { - case C.TYPE_DASH: - return DownloadRequest.TYPE_DASH; - case C.TYPE_HLS: - return DownloadRequest.TYPE_HLS; - case C.TYPE_SS: - return DownloadRequest.TYPE_SS; - case C.TYPE_OTHER: - default: - return DownloadRequest.TYPE_PROGRESSIVE; - } - } - - @Nullable - private static String getMimeType(String downloadType) { - switch (downloadType) { - case DownloadRequest.TYPE_DASH: - return MimeTypes.APPLICATION_MPD; - case DownloadRequest.TYPE_HLS: - return MimeTypes.APPLICATION_M3U8; - case DownloadRequest.TYPE_SS: - return MimeTypes.APPLICATION_SS; - case DownloadRequest.TYPE_PROGRESSIVE: - return null; - default: - throw new IllegalArgumentException(); - } + private static boolean isProgressive(MediaItem.PlaybackProperties playbackProperties) { + return Util.inferContentTypeWithMimeType(playbackProperties.uri, playbackProperties.mimeType) + == C.TYPE_OTHER; } private static final class MediaPreparer diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java index 988b908140..cecb76983b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java @@ -21,6 +21,7 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -35,23 +36,23 @@ public final class DownloadRequest implements Parcelable { /** Thrown when the encoded request data belongs to an unsupported request type. */ public static class UnsupportedRequestException extends IOException {} - /** Type for progressive downloads. */ - public static final String TYPE_PROGRESSIVE = "progressive"; - /** Type for DASH downloads. */ - public static final String TYPE_DASH = "dash"; - /** Type for HLS downloads. */ - public static final String TYPE_HLS = "hls"; - /** Type for SmoothStreaming downloads. */ - public static final String TYPE_SS = "ss"; - /** The unique content id. */ public final String id; - /** The type of the request. */ - public final String type; /** The uri being downloaded. */ public final Uri uri; + /** + * The MIME type of this content. Used as a hint to infer the content's type (DASH, HLS, + * SmoothStreaming). If null, a {@link DownloadService} will infer the content type from the + * {@link #uri}. + */ + @Nullable public final String mimeType; /** Stream keys to be downloaded. If empty, all streams will be downloaded. */ public final List streamKeys; + /** + * The key set id of the offline licence if the content is protected with DRM, or empty if no + * license is needed. + */ + public final byte[] keySetId; /** * Custom key for cache indexing, or null. Must be null for DASH, HLS and SmoothStreaming * downloads. @@ -62,43 +63,48 @@ public final class DownloadRequest implements Parcelable { /** * @param id See {@link #id}. - * @param type See {@link #type}. * @param uri See {@link #uri}. + * @param mimeType See {@link #mimeType} * @param streamKeys See {@link #streamKeys}. * @param customCacheKey See {@link #customCacheKey}. * @param data See {@link #data}. */ public DownloadRequest( String id, - String type, Uri uri, + @Nullable String mimeType, List streamKeys, + @Nullable byte[] keySetId, @Nullable String customCacheKey, @Nullable byte[] data) { - if (TYPE_DASH.equals(type) || TYPE_HLS.equals(type) || TYPE_SS.equals(type)) { + @C.ContentType int contentType = Util.inferContentTypeWithMimeType(uri, mimeType); + if (contentType == C.TYPE_DASH || contentType == C.TYPE_HLS || contentType == C.TYPE_SS) { Assertions.checkArgument( - customCacheKey == null, "customCacheKey must be null for type: " + type); + customCacheKey == null, "customCacheKey must be null for type: " + contentType); } this.id = id; - this.type = type; this.uri = uri; + this.mimeType = mimeType; ArrayList mutableKeys = new ArrayList<>(streamKeys); Collections.sort(mutableKeys); this.streamKeys = Collections.unmodifiableList(mutableKeys); + this.keySetId = + keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : Util.EMPTY_BYTE_ARRAY; this.customCacheKey = customCacheKey; this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY; } /* package */ DownloadRequest(Parcel in) { id = castNonNull(in.readString()); - type = castNonNull(in.readString()); uri = Uri.parse(castNonNull(in.readString())); + mimeType = in.readString(); int streamKeyCount = in.readInt(); ArrayList mutableStreamKeys = new ArrayList<>(streamKeyCount); for (int i = 0; i < streamKeyCount; i++) { mutableStreamKeys.add(in.readParcelable(StreamKey.class.getClassLoader())); } streamKeys = Collections.unmodifiableList(mutableStreamKeys); + keySetId = castNonNull(in.createByteArray()); customCacheKey = in.readString(); data = castNonNull(in.createByteArray()); } @@ -110,24 +116,22 @@ public final class DownloadRequest implements Parcelable { * @return The copy with the specified ID. */ public DownloadRequest copyWithId(String id) { - return new DownloadRequest(id, type, uri, streamKeys, customCacheKey, data); + return new DownloadRequest(id, uri, mimeType, streamKeys, keySetId, customCacheKey, data); } /** * Returns the result of merging {@code newRequest} into this request. The requests must have the - * same {@link #id} and {@link #type}. + * same {@link #id}. * - *

        If the requests have different {@link #uri}, {@link #customCacheKey} and {@link #data} - * values, then those from the request being merged are included in the result. + *

        The resulting request contains the stream keys from both requests. For all other member + * variables, those in {@code newRequest} are preferred. * * @param newRequest The request being merged. * @return The merged result. - * @throws IllegalArgumentException If the requests do not have the same {@link #id} and {@link - * #type}. + * @throws IllegalArgumentException If the requests do not have the same {@link #id}. */ public DownloadRequest copyWithMergedRequest(DownloadRequest newRequest) { Assertions.checkArgument(id.equals(newRequest.id)); - Assertions.checkArgument(type.equals(newRequest.type)); List mergedKeys; if (streamKeys.isEmpty() || newRequest.streamKeys.isEmpty()) { // If either streamKeys is empty then all streams should be downloaded. @@ -142,12 +146,18 @@ public final class DownloadRequest implements Parcelable { } } return new DownloadRequest( - id, type, newRequest.uri, mergedKeys, newRequest.customCacheKey, newRequest.data); + id, + newRequest.uri, + newRequest.mimeType, + mergedKeys, + newRequest.keySetId, + newRequest.customCacheKey, + newRequest.data); } @Override public String toString() { - return type + ":" + id; + return mimeType + ":" + id; } @Override @@ -157,20 +167,21 @@ public final class DownloadRequest implements Parcelable { } DownloadRequest that = (DownloadRequest) o; return id.equals(that.id) - && type.equals(that.type) && uri.equals(that.uri) + && Util.areEqual(mimeType, that.mimeType) && streamKeys.equals(that.streamKeys) + && Arrays.equals(keySetId, that.keySetId) && Util.areEqual(customCacheKey, that.customCacheKey) && Arrays.equals(data, that.data); } @Override public final int hashCode() { - int result = type.hashCode(); - result = 31 * result + id.hashCode(); - result = 31 * result + type.hashCode(); + int result = 31 * id.hashCode(); result = 31 * result + uri.hashCode(); + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); result = 31 * result + streamKeys.hashCode(); + result = 31 * result + Arrays.hashCode(keySetId); result = 31 * result + (customCacheKey != null ? customCacheKey.hashCode() : 0); result = 31 * result + Arrays.hashCode(data); return result; @@ -186,12 +197,13 @@ public final class DownloadRequest implements Parcelable { @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(id); - dest.writeString(type); dest.writeString(uri.toString()); + dest.writeString(mimeType); dest.writeInt(streamKeys.size()); for (int i = 0; i < streamKeys.size(); i++) { dest.writeParcelable(streamKeys.get(i), /* parcelableFlags= */ 0); } + dest.writeByteArray(keySetId); dest.writeString(customCacheKey); dest.writeByteArray(data); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java index 3d07bdcb5a..f74b0ada91 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java @@ -85,16 +85,4 @@ public class VersionTableTest { .isEqualTo(VersionTable.VERSION_UNSET); assertThat(VersionTable.getVersion(database, FEATURE_1, INSTANCE_2)).isEqualTo(2); } - - @Test - public void doesTableExist_nonExistingTable_returnsFalse() { - assertThat(VersionTable.tableExists(database, "NonExistingTable")).isFalse(); - } - - @Test - public void doesTableExist_existingTable_returnsTrue() { - String table = "TestTable"; - databaseProvider.getWritableDatabase().execSQL("CREATE TABLE " + table + " (test INTEGER)"); - assertThat(VersionTable.tableExists(database, table)).isTrue(); - } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java index cec0d07688..ffc34cac30 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java @@ -21,6 +21,7 @@ import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.FileOutputStream; @@ -127,9 +128,10 @@ public class ActionFileTest { private static DownloadRequest buildExpectedRequest(Uri uri, byte[] data) { return new DownloadRequest( /* id= */ uri.toString(), - DownloadRequest.TYPE_PROGRESSIVE, uri, + /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, /* streamKeys= */ Collections.emptyList(), + /* keySetId= */ null, /* customCacheKey= */ null, data); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java index 17c1b57f37..c77dca90ee 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.offline; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_PROGRESSIVE; import static com.google.common.truth.Truth.assertThat; import android.net.Uri; @@ -23,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.FileOutputStream; @@ -74,18 +74,20 @@ public class ActionFileUpgradeUtilTest { new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest expectedRequest1 = new DownloadRequest( - "key123", - /* type= */ "test", + /* id= */ "key123", Uri.parse("https://www.test.com/download1"), + /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, asList(expectedStreamKey1), + /* keySetId= */ null, /* customCacheKey= */ "key123", - new byte[] {1, 2, 3, 4}); + /* data= */ new byte[] {1, 2, 3, 4}); DownloadRequest expectedRequest2 = new DownloadRequest( - "key234", - /* type= */ "test", + /* id= */ "key234", Uri.parse("https://www.test.com/download2"), + /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, asList(expectedStreamKey2), + /* keySetId= */ null, /* customCacheKey= */ "key234", new byte[] {5, 4, 3, 2, 1}); @@ -102,17 +104,17 @@ public class ActionFileUpgradeUtilTest { @Test public void mergeRequest_nonExistingDownload_createsNewDownload() throws IOException { - byte[] data = new byte[] {1, 2, 3, 4}; DownloadRequest request = new DownloadRequest( - "id", - TYPE_PROGRESSIVE, + /* id= */ "id", Uri.parse("https://www.test.com/download"), + /* mimeType= */ null, asList( new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)), + /* keySetId= */ new byte[] {1, 2, 3, 4}, /* customCacheKey= */ "key123", - data); + /* data= */ new byte[] {1, 2, 3, 4}); ActionFileUpgradeUtil.mergeRequest( request, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); @@ -128,32 +130,36 @@ public class ActionFileUpgradeUtilTest { new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest request1 = new DownloadRequest( - "id", - TYPE_PROGRESSIVE, + /* id= */ "id", Uri.parse("https://www.test.com/download1"), + /* mimeType= */ null, asList(streamKey1), + /* keySetId= */ new byte[] {1, 2, 3, 4}, /* customCacheKey= */ "key123", - new byte[] {1, 2, 3, 4}); + /* data= */ new byte[] {1, 2, 3, 4}); DownloadRequest request2 = new DownloadRequest( - "id", - TYPE_PROGRESSIVE, + /* id= */ "id", Uri.parse("https://www.test.com/download2"), + /* mimeType= */ MimeTypes.APPLICATION_MP4, asList(streamKey2), - /* customCacheKey= */ "key123", - new byte[] {5, 4, 3, 2, 1}); + /* keySetId= */ new byte[] {5, 4, 3, 2, 1}, + /* customCacheKey= */ "key345", + /* data= */ new byte[] {5, 4, 3, 2, 1}); + ActionFileUpgradeUtil.mergeRequest( request1, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); ActionFileUpgradeUtil.mergeRequest( request2, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); - Download download = downloadIndex.getDownload(request2.id); + assertThat(download).isNotNull(); - assertThat(download.request.type).isEqualTo(request2.type); + assertThat(download.request.mimeType).isEqualTo(MimeTypes.APPLICATION_MP4); assertThat(download.request.customCacheKey).isEqualTo(request2.customCacheKey); assertThat(download.request.data).isEqualTo(request2.data); assertThat(download.request.uri).isEqualTo(request2.uri); assertThat(download.request.streamKeys).containsExactly(streamKey1, streamKey2); + assertThat(download.request.keySetId).isEqualTo(request2.keySetId); assertThat(download.state).isEqualTo(Download.STATE_QUEUED); } @@ -165,20 +171,22 @@ public class ActionFileUpgradeUtilTest { new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest request1 = new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, + /* id= */ "id1", Uri.parse("https://www.test.com/download1"), + /* mimeType= */ null, asList(streamKey1), + /* keySetId= */ new byte[] {1, 2, 3, 4}, /* customCacheKey= */ "key123", - new byte[] {1, 2, 3, 4}); + /* data= */ new byte[] {1, 2, 3, 4}); DownloadRequest request2 = new DownloadRequest( - "id2", - TYPE_PROGRESSIVE, + /* id= */ "id2", Uri.parse("https://www.test.com/download2"), + /* mimeType= */ null, asList(streamKey2), + /* keySetId= */ new byte[] {5, 4, 3, 2, 1}, /* customCacheKey= */ "key123", - new byte[] {5, 4, 3, 2, 1}); + /* data= */ new byte[] {5, 4, 3, 2, 1}); ActionFileUpgradeUtil.mergeRequest( request1, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java index cc1ae4b71b..313ee86413 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java @@ -15,15 +15,31 @@ */ package com.google.android.exoplayer2.offline; +import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_NONE; +import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_UNKNOWN; +import static com.google.android.exoplayer2.offline.Download.STATE_DOWNLOADING; +import static com.google.android.exoplayer2.offline.Download.STATE_STOPPED; +import static com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.database.DatabaseIOException; import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.database.VersionTable; import com.google.android.exoplayer2.testutil.DownloadBuilder; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -73,14 +89,14 @@ public class DefaultDownloadIndexTest { Download download = downloadBuilder - .setType("different type") .setUri("different uri") + .setMimeType(MimeTypes.APPLICATION_MP4) .setCacheKey("different cacheKey") .setState(Download.STATE_FAILED) .setPercentDownloaded(50) .setBytesDownloaded(200) .setContentLength(400) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN) + .setFailureReason(FAILURE_REASON_UNKNOWN) .setStopReason(0x12345678) .setStartTimeMs(10) .setUpdateTimeMs(20) @@ -88,6 +104,7 @@ public class DefaultDownloadIndexTest { new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)) .setCustomMetadata(new byte[] {0, 1, 2, 3, 7, 8, 9, 10}) + .setKeySetId(new byte[] {0, 1, 2, 3}) .build(); downloadIndex.putDownload(download); Download readDownload = downloadIndex.getDownload(id); @@ -153,7 +170,7 @@ public class DefaultDownloadIndexTest { new DownloadBuilder("id1").setStartTimeMs(0).setState(Download.STATE_REMOVING).build(); downloadIndex.putDownload(download1); Download download2 = - new DownloadBuilder("id2").setStartTimeMs(1).setState(Download.STATE_STOPPED).build(); + new DownloadBuilder("id2").setStartTimeMs(1).setState(STATE_STOPPED).build(); downloadIndex.putDownload(download2); Download download3 = new DownloadBuilder("id3").setStartTimeMs(2).setState(Download.STATE_COMPLETED).build(); @@ -202,6 +219,47 @@ public class DefaultDownloadIndexTest { .isEqualTo(DefaultDownloadIndex.TABLE_VERSION); } + @Test + public void downloadIndex_upgradesFromVersion2() throws IOException { + Context context = ApplicationProvider.getApplicationContext(); + File databaseFile = context.getDatabasePath(ExoDatabaseProvider.DATABASE_NAME); + try (FileOutputStream output = new FileOutputStream(databaseFile)) { + output.write(TestUtil.getByteArray(context, "offline/exoplayer_internal_v2.db")); + } + Download dashDownload = + createDownload( + /* uri= */ "http://www.test.com/manifest.mpd", + /* mimeType= */ MimeTypes.APPLICATION_MPD, + ImmutableList.of(), + /* customCacheKey= */ null); + Download hlsDownload = + createDownload( + /* uri= */ "http://www.test.com/manifest.m3u8", + /* mimeType= */ MimeTypes.APPLICATION_M3U8, + ImmutableList.of(), + /* customCacheKey= */ null); + Download ssDownload = + createDownload( + /* uri= */ "http://www.test.com/video.ism/manifest", + /* mimeType= */ MimeTypes.APPLICATION_SS, + Arrays.asList(new StreamKey(0, 0), new StreamKey(1, 1)), + /* customCacheKey= */ null); + Download progressiveDownload = + createDownload( + /* uri= */ "http://www.test.com/video.mp4", + /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, + ImmutableList.of(), + /* customCacheKey= */ "customCacheKey"); + + databaseProvider = new ExoDatabaseProvider(context); + downloadIndex = new DefaultDownloadIndex(databaseProvider); + + assertEqual(downloadIndex.getDownload("http://www.test.com/manifest.mpd"), dashDownload); + assertEqual(downloadIndex.getDownload("http://www.test.com/manifest.m3u8"), hlsDownload); + assertEqual(downloadIndex.getDownload("http://www.test.com/video.ism/manifest"), ssDownload); + assertEqual(downloadIndex.getDownload("http://www.test.com/video.mp4"), progressiveDownload); + } + @Test public void setStopReason_setReasonToNone() throws Exception { String id = "id"; @@ -210,10 +268,10 @@ public class DefaultDownloadIndexTest { Download download = downloadBuilder.build(); downloadIndex.putDownload(download); - downloadIndex.setStopReason(Download.STOP_REASON_NONE); + downloadIndex.setStopReason(STOP_REASON_NONE); Download readDownload = downloadIndex.getDownload(id); - Download expectedDownload = downloadBuilder.setStopReason(Download.STOP_REASON_NONE).build(); + Download expectedDownload = downloadBuilder.setStopReason(STOP_REASON_NONE).build(); assertEqual(readDownload, expectedDownload); } @@ -223,7 +281,7 @@ public class DefaultDownloadIndexTest { DownloadBuilder downloadBuilder = new DownloadBuilder(id) .setState(Download.STATE_FAILED) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + .setFailureReason(FAILURE_REASON_UNKNOWN); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int stopReason = 0x12345678; @@ -238,7 +296,7 @@ public class DefaultDownloadIndexTest { @Test public void setStopReason_notTerminalState_doesNotSetStopReason() throws Exception { String id = "id"; - DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(Download.STATE_DOWNLOADING); + DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(STATE_DOWNLOADING); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int notMetRequirements = 0x12345678; @@ -255,7 +313,7 @@ public class DefaultDownloadIndexTest { DownloadBuilder downloadBuilder = new DownloadBuilder(id) .setState(Download.STATE_FAILED) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + .setFailureReason(FAILURE_REASON_UNKNOWN); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); @@ -263,7 +321,7 @@ public class DefaultDownloadIndexTest { download = downloadIndex.getDownload(id); assertThat(download.state).isEqualTo(Download.STATE_REMOVING); - assertThat(download.failureReason).isEqualTo(Download.FAILURE_REASON_NONE); + assertThat(download.failureReason).isEqualTo(FAILURE_REASON_NONE); } @Test @@ -274,10 +332,10 @@ public class DefaultDownloadIndexTest { Download download = downloadBuilder.build(); downloadIndex.putDownload(download); - downloadIndex.setStopReason(id, Download.STOP_REASON_NONE); + downloadIndex.setStopReason(id, STOP_REASON_NONE); Download readDownload = downloadIndex.getDownload(id); - Download expectedDownload = downloadBuilder.setStopReason(Download.STOP_REASON_NONE).build(); + Download expectedDownload = downloadBuilder.setStopReason(STOP_REASON_NONE).build(); assertEqual(readDownload, expectedDownload); } @@ -287,7 +345,7 @@ public class DefaultDownloadIndexTest { DownloadBuilder downloadBuilder = new DownloadBuilder(id) .setState(Download.STATE_FAILED) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + .setFailureReason(FAILURE_REASON_UNKNOWN); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int stopReason = 0x12345678; @@ -302,7 +360,7 @@ public class DefaultDownloadIndexTest { @Test public void setSingleDownloadStopReason_notTerminalState_doesNotSetStopReason() throws Exception { String id = "id"; - DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(Download.STATE_DOWNLOADING); + DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(STATE_DOWNLOADING); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int notMetRequirements = 0x12345678; @@ -324,4 +382,23 @@ public class DefaultDownloadIndexTest { assertThat(download.getPercentDownloaded()).isEqualTo(that.getPercentDownloaded()); assertThat(download.getBytesDownloaded()).isEqualTo(that.getBytesDownloaded()); } + + private static Download createDownload( + String uri, String mimeType, List streamKeys, @Nullable String customCacheKey) { + return new Download( + new DownloadRequest( + uri, + Uri.parse(uri), + mimeType, + streamKeys, + /* keySetId= */ null, + customCacheKey, + /* data= */ new byte[] {0, 1, 2, 3}), + /* state= */ STATE_STOPPED, + /* startTimeMs= */ 1, + /* updateTimeMs= */ 2, + /* contentLength= */ 3, + /* stopReason= */ 4, + /* failureReason= */ FAILURE_REASON_NONE); + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java index bf762f0da9..d3789c530e 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java @@ -43,10 +43,11 @@ public final class DefaultDownloaderFactoryTest { Downloader downloader = factory.createDownloader( new DownloadRequest( - "id", - DownloadRequest.TYPE_PROGRESSIVE, + /* id= */ "id", Uri.parse("https://www.test.com/download"), + /* mimeType= */ null, /* streamKeys= */ Collections.emptyList(), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null)); assertThat(downloader).isInstanceOf(ProgressiveDownloader.class); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index bada7fc15c..73917606c5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -403,8 +403,8 @@ public class DownloadHelperTest { DownloadRequest downloadRequest = downloadHelper.getDownloadRequest(data); - assertThat(downloadRequest.type).isEqualTo(DownloadRequest.TYPE_PROGRESSIVE); assertThat(downloadRequest.uri).isEqualTo(testMediaItem.playbackProperties.uri); + assertThat(downloadRequest.mimeType).isEqualTo(testMediaItem.playbackProperties.mimeType); assertThat(downloadRequest.customCacheKey) .isEqualTo(testMediaItem.playbackProperties.customCacheKey); assertThat(downloadRequest.data).isEqualTo(data); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java index 6ec93cbc29..a9a6d3888c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java @@ -790,9 +790,10 @@ public class DownloadManagerTest { private static DownloadRequest createDownloadRequest(String id, StreamKey... keys) { return new DownloadRequest( id, - DownloadRequest.TYPE_DASH, Uri.parse("http://abc.com/ " + id), + /* mimeType= */ null, Arrays.asList(keys), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java index c5b00b02d6..0256215809 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java @@ -15,9 +15,6 @@ */ package com.google.android.exoplayer2.offline; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_DASH; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_HLS; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_PROGRESSIVE; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; @@ -48,44 +45,20 @@ public class DownloadRequestTest { public void mergeRequests_withDifferentIds_fails() { DownloadRequest request1 = new DownloadRequest( - "id1", - TYPE_DASH, + /* id= */ "id1", uri1, + /* mimeType= */ null, /* streamKeys= */ Collections.emptyList(), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null); DownloadRequest request2 = new DownloadRequest( - "id2", - TYPE_DASH, + /* id= */ "id2", uri2, + /* mimeType= */ null, /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); - try { - request1.copyWithMergedRequest(request2); - fail(); - } catch (IllegalArgumentException e) { - // Expected. - } - } - - @Test - public void mergeRequests_withDifferentTypes_fails() { - DownloadRequest request1 = - new DownloadRequest( - "id1", - TYPE_DASH, - uri1, - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); - DownloadRequest request2 = - new DownloadRequest( - "id1", - TYPE_HLS, - uri1, - /* streamKeys= */ Collections.emptyList(), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null); try { @@ -135,33 +108,40 @@ public class DownloadRequestTest { @Test public void mergeRequests_withDifferentFields() { - byte[] data1 = new byte[] {0, 1, 2}; - byte[] data2 = new byte[] {3, 4, 5}; + byte[] keySetId1 = new byte[] {0, 1, 2}; + byte[] keySetId2 = new byte[] {3, 4, 5}; + byte[] data1 = new byte[] {6, 7, 8}; + byte[] data2 = new byte[] {9, 10, 11}; + DownloadRequest request1 = new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, + /* id= */ "id1", uri1, + /* mimeType= */ null, /* streamKeys= */ Collections.emptyList(), - "key1", - /* data= */ data1); + keySetId1, + /* customCacheKey= */ "key1", + data1); DownloadRequest request2 = new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, + /* id= */ "id1", uri2, + /* mimeType= */ null, /* streamKeys= */ Collections.emptyList(), - "key2", - /* data= */ data2); + keySetId2, + /* customCacheKey= */ "key2", + data2); - // uri, customCacheKey and data should be from the request being merged. + // uri, keySetId, customCacheKey and data should be from the request being merged. DownloadRequest mergedRequest = request1.copyWithMergedRequest(request2); assertThat(mergedRequest.uri).isEqualTo(uri2); + assertThat(mergedRequest.keySetId).isEqualTo(keySetId2); assertThat(mergedRequest.customCacheKey).isEqualTo("key2"); assertThat(mergedRequest.data).isEqualTo(data2); mergedRequest = request2.copyWithMergedRequest(request1); assertThat(mergedRequest.uri).isEqualTo(uri1); + assertThat(mergedRequest.keySetId).isEqualTo(keySetId1); assertThat(mergedRequest.customCacheKey).isEqualTo("key1"); assertThat(mergedRequest.data).isEqualTo(data1); } @@ -173,12 +153,13 @@ public class DownloadRequestTest { streamKeys.add(new StreamKey(4, 5, 6)); DownloadRequest requestToParcel = new DownloadRequest( - "id", - "type", + /* id= */ "id", Uri.parse("https://abc.def/ghi"), + /* mimeType= */ null, streamKeys, - "key", - new byte[] {1, 2, 3, 4, 5}); + /* keySetId= */ new byte[] {1, 2, 3, 4, 5}, + /* customCacheKey= */ "key", + /* data= */ new byte[] {1, 2, 3, 4, 5}); Parcel parcel = Parcel.obtain(); requestToParcel.writeToParcel(parcel, 0); parcel.setDataPosition(0); @@ -232,11 +213,18 @@ public class DownloadRequestTest { private static void assertEqual(DownloadRequest request1, DownloadRequest request2) { assertThat(request1).isEqualTo(request2); assertThat(request2).isEqualTo(request1); + assertThat(request1.hashCode()).isEqualTo(request2.hashCode()); } private static DownloadRequest createRequest(Uri uri, StreamKey... keys) { return new DownloadRequest( - uri.toString(), TYPE_DASH, uri, toList(keys), /* customCacheKey= */ null, /* data= */ null); + uri.toString(), + uri, + /* mimeType= */ null, + toList(keys), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ null); } private static List toList(StreamKey... keys) { diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java index 17c6da74dc..b5a2bf3057 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; @@ -93,9 +94,10 @@ public class DashDownloaderTest { factory.createDownloader( new DownloadRequest( "id", - DownloadRequest.TYPE_DASH, Uri.parse("https://www.test.com/download"), + MimeTypes.APPLICATION_MPD, Collections.singletonList(new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0)), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null)); assertThat(downloader).isInstanceOf(DashDownloader.class); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index d2756780f8..cdee3becdb 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -42,6 +42,7 @@ import com.google.android.exoplayer2.upstream.DataSource.Factory; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.util.ArrayList; @@ -224,9 +225,10 @@ public class DownloadManagerDashTest { Collections.addAll(keysList, keys); return new DownloadRequest( TEST_ID, - DownloadRequest.TYPE_DASH, TEST_MPD_URI, + MimeTypes.APPLICATION_MPD, keysList, + /* keySetId= */ null, /* customCacheKey= */ null, null); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index 1257a70a07..8dc88bffe5 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; @@ -209,9 +210,10 @@ public class DownloadServiceDashTest { DownloadRequest action = new DownloadRequest( TEST_ID, - DownloadRequest.TYPE_DASH, TEST_MPD_URI, + MimeTypes.APPLICATION_MPD, keysList, + /* keySetId= */ null, /* customCacheKey= */ null, null); testThread.runOnMainThread( diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java index 1fc51ab498..d968af51bc 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java @@ -53,6 +53,7 @@ import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.util.ArrayList; @@ -111,9 +112,10 @@ public class HlsDownloaderTest { factory.createDownloader( new DownloadRequest( "id", - DownloadRequest.TYPE_HLS, Uri.parse("https://www.test.com/download"), + MimeTypes.APPLICATION_M3U8, Collections.singletonList(new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0)), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null)); assertThat(downloader).isInstanceOf(HlsDownloader.class); diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java index 1e6ea37365..6d1bb28009 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -49,9 +50,10 @@ public final class SsDownloaderTest { factory.createDownloader( new DownloadRequest( "id", - DownloadRequest.TYPE_SS, Uri.parse("https://www.test.com/download"), + MimeTypes.APPLICATION_SS, Collections.singletonList(new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0)), + /* keySetId= */ null, /* customCacheKey= */ null, /* data= */ null)); assertThat(downloader).isInstanceOf(SsDownloader.class); diff --git a/testdata/src/test/assets/offline/exoplayer_internal_v2.db b/testdata/src/test/assets/offline/exoplayer_internal_v2.db new file mode 100644 index 0000000000000000000000000000000000000000..63f14052bf6b422d821668af096f22604b96f571 GIT binary patch literal 40960 zcmeI*O>g5w7zc1q>^3h;YYG1 z`s|I@i_JSf=C<#AlDG#lApijgKmY;|fB*z;xWM^oI-RGKTztgobDLFPd5>&*#{3;> zwAp8t&Gs0lCgpVV9&4>_Y*y~wH|gg32OrK}GrMz8-D*~~=GJz-s?ENiw&GHUY0c^v zP3_Ua?%vkHvG%ZftTpzVTH~l*FWSs~!Q0wyqgkz04+ipSz;*6*jkeA$hXtHEov~U4 zo=NA=)09Y-YqD3tSGM>~TBlv^^`7gqk5jMAUN?8%m`~>q56Oj`;Jq7;WIA+WmCO|3 z#2vI1%Zx=5ZC|ppOZiyai2^~}uJ3Qh*oD^(C7rKS$k(;u1NziEb#0HDL5vgU%qI^w zF7J(Q@@eN5x1+F#2iNtjk>Lj1XVlTfaRehJMmOM|qZ?s_hvmKEVi@DYhgKAUBPLg4 z;U%(t5nm#*bSD@u?sh~|Y;w8bxtzINpA@}fa2;KYJ@DM|##_{~yFQy(-eJBGHf~1x z%+$n-uO6SS;`1}kg2Cd^!z=8}&F7y~u6J-)Au7`z;r>!IlbzEqOkF zRKC`sKf(cqepWc;&V)bzpOE?d>MAjM7SuI;&ocG?>uL14JR~t%s;$_bL8JIDgp>H* zO#Yg?uo@BsAOHafKmY;|fB*y_009U<00RF{fdw&aqW&p~ZzKpn00Izz00bZa0SG_< z0uX=z1a6l=nxyi~)sGIt`M<3GEUAB~zl#kL1Rwwb2tWV=5P$##AOHafKmYMxS|xB7?JAVB~E5P$##AOHafKmY;|fB*y_ zaN`Bu5eoohYym)C5(@xibO9ixhy{Rbc>P~ld?~5ltF^^n#RDV=KmY;|fB*y_009U< z00Izzz<(346t%WoTPWPkW(gsgOs36w=i_qu^z^hOK2ngE49_W_SSIsIPG_UzdoO$z z1l9?gTJQ3+7<(T!`jDMTBqgV0#go#dc$KmY;|fB*y_009U<00I!W H-2(prU`@Na literal 0 HcmV?d00001 diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DownloadBuilder.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DownloadBuilder.java index 1b707b3d3a..cc5565a69c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DownloadBuilder.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DownloadBuilder.java @@ -38,9 +38,10 @@ public final class DownloadBuilder { private final DownloadProgress progress; private String id; - private String type; private Uri uri; + @Nullable private String mimeType; private List streamKeys; + private byte[] keySetId; @Nullable private String cacheKey; private byte[] customMetadata; @@ -52,18 +53,19 @@ public final class DownloadBuilder { private int failureReason; /** - * Creates a download builder for "uri" with type "type" and no stream keys. + * Creates a download builder for "uri" and no stream keys. * * @param id The unique content identifier for the download. */ public DownloadBuilder(String id) { this( id, - "type", Uri.parse("uri"), + /* mimeType= */ null, /* streamKeys= */ Collections.emptyList(), + /* keySetId= */ new byte[0], /* cacheKey= */ null, - new byte[0]); + /* customMetadata= */ new byte[0]); } /** @@ -74,9 +76,10 @@ public final class DownloadBuilder { public DownloadBuilder(DownloadRequest request) { this( request.id, - request.type, request.uri, + request.mimeType, request.streamKeys, + request.keySetId, request.customCacheKey, request.data); } @@ -84,15 +87,17 @@ public final class DownloadBuilder { /** Creates a download builder. */ private DownloadBuilder( String id, - String type, Uri uri, + @Nullable String mimeType, List streamKeys, + byte[] keySetId, @Nullable String cacheKey, byte[] customMetadata) { this.id = id; - this.type = type; this.uri = uri; + this.mimeType = mimeType; this.streamKeys = streamKeys; + this.keySetId = keySetId; this.cacheKey = cacheKey; this.customMetadata = customMetadata; this.state = Download.STATE_QUEUED; @@ -101,12 +106,6 @@ public final class DownloadBuilder { this.progress = new DownloadProgress(); } - /** @see DownloadRequest#type */ - public DownloadBuilder setType(String type) { - this.type = type; - return this; - } - /** @see DownloadRequest#uri */ public DownloadBuilder setUri(String uri) { this.uri = Uri.parse(uri); @@ -119,6 +118,18 @@ public final class DownloadBuilder { return this; } + /** @see DownloadRequest#mimeType */ + public DownloadBuilder setMimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + /** @see DownloadRequest#keySetId */ + public DownloadBuilder setKeySetId(byte[] keySetId) { + this.keySetId = keySetId; + return this; + } + /** @see DownloadRequest#customCacheKey */ public DownloadBuilder setCacheKey(@Nullable String cacheKey) { this.cacheKey = cacheKey; @@ -187,7 +198,7 @@ public final class DownloadBuilder { public Download build() { DownloadRequest request = - new DownloadRequest(id, type, uri, streamKeys, cacheKey, customMetadata); + new DownloadRequest(id, uri, mimeType, streamKeys, keySetId, cacheKey, customMetadata); return new Download( request, state, From b7b3f4ea4522e839c86736f328817d3fb65fa9a8 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 20 Jul 2020 12:57:41 +0100 Subject: [PATCH 0712/1052] Remove unused MEDIA_ITEM_TRANSITION_REASON_SKIP PiperOrigin-RevId: 322120882 --- .../src/main/java/com/google/android/exoplayer2/Player.java | 5 +---- .../java/com/google/android/exoplayer2/util/EventLogger.java | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 6dd2753869..edfe1197e3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -782,7 +782,6 @@ public interface Player { MEDIA_ITEM_TRANSITION_REASON_REPEAT, MEDIA_ITEM_TRANSITION_REASON_AUTO, MEDIA_ITEM_TRANSITION_REASON_SEEK, - MEDIA_ITEM_TRANSITION_REASON_SKIP, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED }) @interface MediaItemTransitionReason {} @@ -792,14 +791,12 @@ public interface Player { int MEDIA_ITEM_TRANSITION_REASON_AUTO = 1; /** A seek to another media item has occurred. */ int MEDIA_ITEM_TRANSITION_REASON_SEEK = 2; - /** Playback skipped to a new media item (for example after failure). */ - int MEDIA_ITEM_TRANSITION_REASON_SKIP = 3; /** * The current media item has changed because of a change in the playlist. This can either be if * the media item previously being played has been removed, or when the playlist becomes non-empty * after being empty. */ - int MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED = 4; + int MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED = 3; /** The default playback speed. */ float DEFAULT_PLAYBACK_SPEED = 1.0f; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index 207b247af5..083c3df2e4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -681,8 +681,6 @@ public class EventLogger implements AnalyticsListener { return "REPEAT"; case Player.MEDIA_ITEM_TRANSITION_REASON_SEEK: return "SEEK"; - case Player.MEDIA_ITEM_TRANSITION_REASON_SKIP: - return "SKIP"; default: return "?"; } From 72728ba0544245b3fa20557864b67ea54d2f75db Mon Sep 17 00:00:00 2001 From: claincly Date: Mon, 20 Jul 2020 13:22:36 +0100 Subject: [PATCH 0713/1052] Remove Media Tunneling support in demo app - Removed Tunneling option from popup menu - Removed intent parameter regarding tunneling - Removed string definition regarding tunneling PiperOrigin-RevId: 322123578 --- .../java/com/google/android/exoplayer2/demo/IntentUtil.java | 1 - .../com/google/android/exoplayer2/demo/PlayerActivity.java | 4 ---- .../android/exoplayer2/demo/SampleChooserActivity.java | 6 ------ demos/main/src/main/res/menu/sample_chooser_menu.xml | 4 ---- demos/main/src/main/res/values/strings.xml | 2 -- 5 files changed, 17 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java index 474ec25db6..7a27b600be 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -93,7 +93,6 @@ public class IntentUtil { public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid"; public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders"; - public static final String TUNNELING_EXTRA = "tunneling"; /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */ public static List createMediaItemsFromIntent( diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 5983f41255..3f80390618 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -155,10 +155,6 @@ public class PlayerActivity extends AppCompatActivity } else { DefaultTrackSelector.ParametersBuilder builder = new DefaultTrackSelector.ParametersBuilder(/* context= */ this); - boolean tunneling = intent.getBooleanExtra(IntentUtil.TUNNELING_EXTRA, false); - if (Util.SDK_INT >= 21 && tunneling) { - builder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(/* context= */ this)); - } trackSelectorParameters = builder.build(); clearStartPosition(); } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index bd340b7436..036ea47f7e 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -80,7 +80,6 @@ public class SampleChooserActivity extends AppCompatActivity private SampleAdapter sampleAdapter; private MenuItem preferExtensionDecodersMenuItem; private MenuItem randomAbrMenuItem; - private MenuItem tunnelingMenuItem; private ExpandableListView sampleListView; @Override @@ -138,10 +137,6 @@ public class SampleChooserActivity extends AppCompatActivity preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders); preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers); randomAbrMenuItem = menu.findItem(R.id.random_abr); - tunnelingMenuItem = menu.findItem(R.id.tunneling); - if (Util.SDK_INT < 21) { - tunnelingMenuItem.setEnabled(false); - } return true; } @@ -240,7 +235,6 @@ public class SampleChooserActivity extends AppCompatActivity ? IntentUtil.ABR_ALGORITHM_RANDOM : IntentUtil.ABR_ALGORITHM_DEFAULT; intent.putExtra(IntentUtil.ABR_ALGORITHM_EXTRA, abrAlgorithm); - intent.putExtra(IntentUtil.TUNNELING_EXTRA, isNonNullAndChecked(tunnelingMenuItem)); IntentUtil.addToIntent(playlistHolder.mediaItems, intent); startActivity(intent); return true; diff --git a/demos/main/src/main/res/menu/sample_chooser_menu.xml b/demos/main/src/main/res/menu/sample_chooser_menu.xml index f95c0b6460..9934e9db95 100644 --- a/demos/main/src/main/res/menu/sample_chooser_menu.xml +++ b/demos/main/src/main/res/menu/sample_chooser_menu.xml @@ -23,8 +23,4 @@ android:title="@string/random_abr" android:checkable="true" app:showAsAction="never"/> - diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index fab74d03cc..d61f08852c 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -69,6 +69,4 @@ Enable random ABR - Request multimedia tunneling - From ecc834d70431f6b8e0d5ac9bc011d9bb6587ff9f Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Mon, 20 Jul 2020 14:30:23 +0100 Subject: [PATCH 0714/1052] Make DrmSessionManager.getExoMediaCryptoType cover placeholder sessions getExoMediaCryptoType will only return null for drmInitData == null and track types for which placeholder sessions are not used. This change will allow renderers to abstract themselves from format.drmInitData. PiperOrigin-RevId: 322131219 --- .../drm/DefaultDrmSessionManager.java | 15 ++++++++--- .../exoplayer2/drm/DrmSessionManager.java | 25 +++++++++++++++---- .../exoplayer2/drm/DummyExoMediaDrm.java | 6 ++--- .../android/exoplayer2/drm/ExoMediaDrm.java | 6 +---- .../source/ProgressiveMediaPeriod.java | 3 ++- .../source/dash/DashMediaPeriod.java | 3 ++- .../source/hls/HlsSampleStreamWrapper.java | 3 ++- .../source/smoothstreaming/SsMediaPeriod.java | 5 +++- .../exoplayer2/testutil/FakeExoMediaDrm.java | 3 +-- 9 files changed, 45 insertions(+), 24 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 890c2dac28..fb23005b82 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -578,10 +578,17 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override @Nullable - public Class getExoMediaCryptoType(DrmInitData drmInitData) { - return canAcquireSession(drmInitData) - ? Assertions.checkNotNull(exoMediaDrm).getExoMediaCryptoType() - : null; + public Class getExoMediaCryptoType( + @Nullable DrmInitData drmInitData, int trackType) { + Class exoMediaCryptoType = + Assertions.checkNotNull(exoMediaDrm).getExoMediaCryptoType(); + if (drmInitData == null) { + return Util.linearSearch(useDrmSessionsForClearContentTrackTypes, trackType) != C.INDEX_UNSET + ? exoMediaCryptoType + : null; + } else { + return canAcquireSession(drmInitData) ? exoMediaCryptoType : UnsupportedMediaCrypto.class; + } } // Internal methods. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java index 69da101837..df2536b6f8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java @@ -49,8 +49,9 @@ public interface DrmSessionManager { @Override @Nullable - public Class getExoMediaCryptoType(DrmInitData drmInitData) { - return null; + public Class getExoMediaCryptoType( + @Nullable DrmInitData drmInitData, int trackType) { + return drmInitData != null ? UnsupportedMediaCrypto.class : null; } }; @@ -118,9 +119,23 @@ public interface DrmSessionManager { DrmInitData drmInitData); /** - * Returns the {@link ExoMediaCrypto} type returned by sessions acquired using the given {@link - * DrmInitData}, or null if a session cannot be acquired with the given {@link DrmInitData}. + * Returns the {@link ExoMediaCrypto} type associated to sessions acquired using the given + * parameters. Returns the {@link UnsupportedMediaCrypto} type if this DRM session manager does + * not support the given {@link DrmInitData}. If {@code drmInitData} is null, returns an {@link + * ExoMediaCrypto} type if this DRM session manager would associate a {@link + * #acquirePlaceholderSession placeholder session} to the given {@code trackType}, or null + * otherwise. + * + * @param drmInitData The {@link DrmInitData} to acquire sessions with. May be null for + * unencrypted content (See {@link #acquirePlaceholderSession placeholder sessions}). + * @param trackType The type of the track to which {@code drmInitData} belongs. Must be one of the + * {@link C}{@code .TRACK_TYPE_*} constants. + * @return The {@link ExoMediaCrypto} type associated to sessions acquired using the given + * parameters, or the {@link UnsupportedMediaCrypto} type if the provided {@code drmInitData} + * is not supported, or {@code null} if {@code drmInitData} is null and no DRM session will be + * associated to the given {@code trackType}. */ @Nullable - Class getExoMediaCryptoType(DrmInitData drmInitData); + Class getExoMediaCryptoType( + @Nullable DrmInitData drmInitData, int trackType); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java index d8311f6701..9631b76491 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java @@ -142,9 +142,7 @@ public final class DummyExoMediaDrm implements ExoMediaDrm { } @Override - @Nullable - public Class getExoMediaCryptoType() { - // No ExoMediaCrypto type is supported. - return null; + public Class getExoMediaCryptoType() { + return UnsupportedMediaCrypto.class; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java index 957945fa2a..6684064f63 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java @@ -369,10 +369,6 @@ public interface ExoMediaDrm { */ ExoMediaCrypto createMediaCrypto(byte[] sessionId) throws MediaCryptoException; - /** - * Returns the {@link ExoMediaCrypto} type created by {@link #createMediaCrypto(byte[])}, or null - * if this instance cannot create any {@link ExoMediaCrypto} instances. - */ - @Nullable + /** Returns the {@link ExoMediaCrypto} type created by {@link #createMediaCrypto(byte[])}. */ Class getExoMediaCryptoType(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index cd1b49d101..77901cc3fb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -787,7 +787,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; if (trackFormat.drmInitData != null) { trackFormat = trackFormat.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(trackFormat.drmInitData)); + drmSessionManager.getExoMediaCryptoType( + trackFormat.drmInitData, MimeTypes.getTrackType(trackFormat.sampleMimeType))); } trackArray[i] = new TrackGroup(trackFormat); } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 3d5f05268b..f466f8eae0 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -674,7 +674,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (drmInitData != null) { format = format.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(drmInitData)); + drmSessionManager.getExoMediaCryptoType( + drmInitData, MimeTypes.getTrackType(format.sampleMimeType))); } formats[j] = format; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index e7f55807f8..f599938cfe 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -1313,7 +1313,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (format.drmInitData != null) { format = format.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(format.drmInitData)); + drmSessionManager.getExoMediaCryptoType( + format.drmInitData, MimeTypes.getTrackType(format.sampleMimeType))); } exposedFormats[j] = format; } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java index 6fe999661c..80743fd9a2 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java @@ -36,6 +36,7 @@ import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.MimeTypes; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -274,7 +275,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; exposedFormats[j] = manifestFormat.drmInitData != null ? manifestFormat.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(manifestFormat.drmInitData)) + drmSessionManager.getExoMediaCryptoType( + manifestFormat.drmInitData, + MimeTypes.getTrackType(manifestFormat.sampleMimeType))) : manifestFormat; } trackGroups[i] = new TrackGroup(exposedFormats); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java index c698b2e8b3..19ea68cbf2 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java @@ -269,9 +269,8 @@ public final class FakeExoMediaDrm implements ExoMediaDrm { return new FakeExoMediaCrypto(); } - @Nullable @Override - public Class getExoMediaCryptoType() { + public Class getExoMediaCryptoType() { return FakeExoMediaCrypto.class; } From ee222f702762e33e100b3613599817cb826a30e9 Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 20 Jul 2020 14:33:19 +0100 Subject: [PATCH 0715/1052] Remove AtomParsers from extractors nullness exclusion list PiperOrigin-RevId: 322131697 --- .../exoplayer2/extractor/mp4/AtomParsers.java | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index a20776595e..de2f4b5d9b 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.MimeTypes.getMimeTypeFromMp4ObjectType; import android.util.Pair; @@ -104,7 +105,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable DrmInitData drmInitData, boolean ignoreEditLists, boolean isQuickTime, - Function modifyTrackFunction) + Function<@NullableType Track, @NullableType Track> modifyTrackFunction) throws ParserException { List trackSampleTables = new ArrayList<>(); for (int i = 0; i < moov.containerChildren.size(); i++) { @@ -117,7 +118,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; modifyTrackFunction.apply( parseTrak( atom, - moov.getLeafAtomOfType(Atom.TYPE_mvhd), + checkNotNull(moov.getLeafAtomOfType(Atom.TYPE_mvhd)), duration, drmInitData, ignoreEditLists, @@ -126,9 +127,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; continue; } Atom.ContainerAtom stblAtom = - atom.getContainerAtomOfType(Atom.TYPE_mdia) - .getContainerAtomOfType(Atom.TYPE_minf) - .getContainerAtomOfType(Atom.TYPE_stbl); + checkNotNull( + checkNotNull( + checkNotNull(atom.getContainerAtomOfType(Atom.TYPE_mdia)) + .getContainerAtomOfType(Atom.TYPE_minf)) + .getContainerAtomOfType(Atom.TYPE_stbl)); TrackSampleTable trackSampleTable = parseStbl(track, stblAtom, gaplessInfoHolder); trackSampleTables.add(trackSampleTable); } @@ -241,13 +244,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; boolean ignoreEditLists, boolean isQuickTime) throws ParserException { - Atom.ContainerAtom mdia = trak.getContainerAtomOfType(Atom.TYPE_mdia); - int trackType = getTrackTypeForHdlr(parseHdlr(mdia.getLeafAtomOfType(Atom.TYPE_hdlr).data)); + Atom.ContainerAtom mdia = checkNotNull(trak.getContainerAtomOfType(Atom.TYPE_mdia)); + int trackType = + getTrackTypeForHdlr(parseHdlr(checkNotNull(mdia.getLeafAtomOfType(Atom.TYPE_hdlr)).data)); if (trackType == C.TRACK_TYPE_UNKNOWN) { return null; } - TkhdData tkhdData = parseTkhd(trak.getLeafAtomOfType(Atom.TYPE_tkhd).data); + TkhdData tkhdData = parseTkhd(checkNotNull(trak.getLeafAtomOfType(Atom.TYPE_tkhd)).data); if (duration == C.TIME_UNSET) { duration = tkhdData.duration; } @@ -258,12 +262,21 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } else { durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, movieTimescale); } - Atom.ContainerAtom stbl = mdia.getContainerAtomOfType(Atom.TYPE_minf) - .getContainerAtomOfType(Atom.TYPE_stbl); + Atom.ContainerAtom stbl = + checkNotNull( + checkNotNull(mdia.getContainerAtomOfType(Atom.TYPE_minf)) + .getContainerAtomOfType(Atom.TYPE_stbl)); - Pair mdhdData = parseMdhd(mdia.getLeafAtomOfType(Atom.TYPE_mdhd).data); - StsdData stsdData = parseStsd(stbl.getLeafAtomOfType(Atom.TYPE_stsd).data, tkhdData.id, - tkhdData.rotationDegrees, mdhdData.second, drmInitData, isQuickTime); + Pair mdhdData = + parseMdhd(checkNotNull(mdia.getLeafAtomOfType(Atom.TYPE_mdhd)).data); + StsdData stsdData = + parseStsd( + checkNotNull(stbl.getLeafAtomOfType(Atom.TYPE_stsd)).data, + tkhdData.id, + tkhdData.rotationDegrees, + mdhdData.second, + drmInitData, + isQuickTime); @Nullable long[] editListDurations = null; @Nullable long[] editListMediaTimes = null; if (!ignoreEditLists) { @@ -323,13 +336,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable Atom.LeafAtom chunkOffsetsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stco); if (chunkOffsetsAtom == null) { chunkOffsetsAreLongs = true; - chunkOffsetsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_co64); + chunkOffsetsAtom = checkNotNull(stblAtom.getLeafAtomOfType(Atom.TYPE_co64)); } ParsableByteArray chunkOffsets = chunkOffsetsAtom.data; // Entries are (chunk number, number of samples per chunk, sample description index). - ParsableByteArray stsc = stblAtom.getLeafAtomOfType(Atom.TYPE_stsc).data; + ParsableByteArray stsc = checkNotNull(stblAtom.getLeafAtomOfType(Atom.TYPE_stsc)).data; // Entries are (number of samples, timestamp delta between those samples). - ParsableByteArray stts = stblAtom.getLeafAtomOfType(Atom.TYPE_stts).data; + ParsableByteArray stts = checkNotNull(stblAtom.getLeafAtomOfType(Atom.TYPE_stts)).data; // Entries are the indices of samples that are synchronization samples. @Nullable Atom.LeafAtom stssAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stss); @Nullable ParsableByteArray stss = stssAtom != null ? stssAtom.data : null; @@ -454,7 +467,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; flags[i] = C.BUFFER_FLAG_KEY_FRAME; remainingSynchronizationSamples--; if (remainingSynchronizationSamples > 0) { - nextSynchronizationSampleIndex = stss.readUnsignedIntToInt() - 1; + nextSynchronizationSampleIndex = checkNotNull(stss).readUnsignedIntToInt() - 1; } } @@ -481,13 +494,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // If the stbl's child boxes are not consistent the container is malformed, but the stream may // still be playable. boolean isCttsValid = true; - while (remainingTimestampOffsetChanges > 0) { - if (ctts.readUnsignedIntToInt() != 0) { - isCttsValid = false; - break; + if (ctts != null) { + while (remainingTimestampOffsetChanges > 0) { + if (ctts.readUnsignedIntToInt() != 0) { + isCttsValid = false; + break; + } + ctts.readInt(); // Ignore offset. + remainingTimestampOffsetChanges--; } - ctts.readInt(); // Ignore offset. - remainingTimestampOffsetChanges--; } if (remainingSynchronizationSamples != 0 || remainingSamplesAtTimestampDelta != 0 @@ -530,7 +545,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (track.editListDurations.length == 1 && track.type == C.TRACK_TYPE_AUDIO && timestamps.length >= 2) { - long editStartTime = track.editListMediaTimes[0]; + long editStartTime = checkNotNull(track.editListMediaTimes)[0]; long editEndTime = editStartTime + Util.scaleLargeTimestamp(track.editListDurations[0], track.timescale, track.movieTimescale); if (canApplyEditWithGaplessInfo(timestamps, duration, editStartTime, editEndTime)) { @@ -557,7 +572,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // The current version of the spec leaves handling of an edit with zero segment_duration in // unfragmented files open to interpretation. We handle this as a special case and include all // samples in the edit. - long editStartTime = track.editListMediaTimes[0]; + long editStartTime = checkNotNull(track.editListMediaTimes)[0]; for (int i = 0; i < timestamps.length; i++) { timestamps[i] = Util.scaleLargeTimestamp( @@ -578,8 +593,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; boolean copyMetadata = false; int[] startIndices = new int[track.editListDurations.length]; int[] endIndices = new int[track.editListDurations.length]; + long[] editListMediaTimes = checkNotNull(track.editListMediaTimes); for (int i = 0; i < track.editListDurations.length; i++) { - long editMediaTime = track.editListMediaTimes[i]; + long editMediaTime = editListMediaTimes[i]; if (editMediaTime != -1) { long editDuration = Util.scaleLargeTimestamp( @@ -1363,7 +1379,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // Set the MIME type based on the object type indication (ISO/IEC 14496-1 table 5). int objectTypeIndication = parent.readUnsignedByte(); - String mimeType = getMimeTypeFromMp4ObjectType(objectTypeIndication); + @Nullable String mimeType = getMimeTypeFromMp4ObjectType(objectTypeIndication); if (MimeTypes.AUDIO_MPEG.equals(mimeType) || MimeTypes.AUDIO_DTS.equals(mimeType) || MimeTypes.AUDIO_DTS_HD.equals(mimeType)) { @@ -1395,8 +1411,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Assertions.checkState(childAtomSize > 0, "childAtomSize should be positive"); int childAtomType = parent.readInt(); if (childAtomType == Atom.TYPE_sinf) { - Pair result = parseCommonEncryptionSinfFromParent(parent, - childPosition, childAtomSize); + @Nullable + Pair result = + parseCommonEncryptionSinfFromParent(parent, childPosition, childAtomSize); if (result != null) { return result; } From 953db7898eed66684ce84052808fa1bd650fbda5 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 20 Jul 2020 15:47:34 +0100 Subject: [PATCH 0716/1052] Make dependency on AndroidX appcompat optional for dialog builder. The dependency is only used to create a dialog in TrackSelectionDialogBuilder that is compatible with newer styling options. This dependendy adds over 500Kb to the apk (even if unused) and we shoudn't force this on an app. Instead make the dependency optional by automatically falling back to the platform version if the AndroidX one doesn't exist. Issue: #7357 PiperOrigin-RevId: 322143005 --- library/ui/build.gradle | 1 - library/ui/proguard-rules.txt | 18 +++++ .../ui/TrackSelectionDialogBuilder.java | 79 +++++++++++++++---- library/ui/src/main/proguard-rules.txt | 1 + 4 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 library/ui/proguard-rules.txt create mode 120000 library/ui/src/main/proguard-rules.txt diff --git a/library/ui/build.gradle b/library/ui/build.gradle index 5b24cfbc62..3825a15d92 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -19,7 +19,6 @@ dependencies { implementation project(modulePrefix + 'library-core') api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion implementation 'androidx.recyclerview:recyclerview:' + androidxRecyclerViewVersion implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/library/ui/proguard-rules.txt b/library/ui/proguard-rules.txt new file mode 100644 index 0000000000..9bfde914b2 --- /dev/null +++ b/library/ui/proguard-rules.txt @@ -0,0 +1,18 @@ +# Proguard rules specific to the UI module. + +# Constructor method accessed via reflection in TrackSelectionDialogBuilder +-dontnote androidx.appcompat.app.AlertDialog.Builder +-keepclassmembers class androidx.appcompat.app.AlertDialog$Builder { + (android.content.Context); + public android.content.Context getContext(); + public androidx.appcompat.app.AlertDialog$Builder setTitle(java.lang.CharSequence); + public androidx.appcompat.app.AlertDialog$Builder setView(android.view.View); + public androidx.appcompat.app.AlertDialog$Builder setPositiveButton(int, android.content.DialogInterface$OnClickListener); + public androidx.appcompat.app.AlertDialog$Builder setNegativeButton(int, android.content.DialogInterface$OnClickListener); + public androidx.appcompat.app.AlertDialog create(); +} + +# Don't warn about checkerframework and Kotlin annotations +-dontwarn org.checkerframework.** +-dontwarn kotlin.annotations.jvm.** +-dontwarn javax.annotation.** diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java index 5c91645a4c..30098054ef 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java @@ -15,18 +15,21 @@ */ package com.google.android.exoplayer2.ui; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; +import android.content.DialogInterface; import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelectionUtil; -import com.google.android.exoplayer2.util.Assertions; +import java.lang.reflect.Constructor; import java.util.Collections; import java.util.List; @@ -97,7 +100,7 @@ public final class TrackSelectionDialogBuilder { Context context, CharSequence title, DefaultTrackSelector trackSelector, int rendererIndex) { this.context = context; this.title = title; - this.mappedTrackInfo = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + this.mappedTrackInfo = checkNotNull(trackSelector.getCurrentMappedTrackInfo()); this.rendererIndex = rendererIndex; TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); @@ -205,24 +208,18 @@ public final class TrackSelectionDialogBuilder { } /** Builds the dialog. */ - public AlertDialog build() { + public Dialog build() { + @Nullable Dialog dialog = buildForAndroidX(); + return dialog == null ? buildForPlatform() : dialog; + } + + private Dialog buildForPlatform() { AlertDialog.Builder builder = new AlertDialog.Builder(context); // Inflate with the builder's context to ensure the correct style is used. LayoutInflater dialogInflater = LayoutInflater.from(builder.getContext()); View dialogView = dialogInflater.inflate(R.layout.exo_track_selection_dialog, /* root= */ null); - - TrackSelectionView selectionView = dialogView.findViewById(R.id.exo_track_selection_view); - selectionView.setAllowMultipleOverrides(allowMultipleOverrides); - selectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); - selectionView.setShowDisableOption(showDisableOption); - if (trackNameProvider != null) { - selectionView.setTrackNameProvider(trackNameProvider); - } - selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null); - Dialog.OnClickListener okClickListener = - (dialog, which) -> - callback.onTracksSelected(selectionView.getIsDisabled(), selectionView.getOverrides()); + Dialog.OnClickListener okClickListener = setUpDialogView(dialogView); return builder .setTitle(title) @@ -231,4 +228,54 @@ public final class TrackSelectionDialogBuilder { .setNegativeButton(android.R.string.cancel, null) .create(); } + + // Reflection calls can't verify null safety of return values or parameters. + @SuppressWarnings("nullness:argument.type.incompatible") + @Nullable + private Dialog buildForAndroidX() { + try { + // This method uses reflection to avoid a dependency on AndroidX appcompat that adds 800KB to + // the APK size even with shrinking. See https://issuetracker.google.com/161514204. + // LINT.IfChange + Class builderClazz = Class.forName("androidx.appcompat.app.AlertDialog$Builder"); + Constructor builderConstructor = builderClazz.getConstructor(Context.class); + Object builder = builderConstructor.newInstance(context); + + // Inflate with the builder's context to ensure the correct style is used. + Context builderContext = (Context) builderClazz.getMethod("getContext").invoke(builder); + LayoutInflater dialogInflater = LayoutInflater.from(builderContext); + View dialogView = + dialogInflater.inflate(R.layout.exo_track_selection_dialog, /* root= */ null); + Dialog.OnClickListener okClickListener = setUpDialogView(dialogView); + + builderClazz.getMethod("setTitle", CharSequence.class).invoke(builder, title); + builderClazz.getMethod("setView", View.class).invoke(builder, dialogView); + builderClazz + .getMethod("setPositiveButton", int.class, DialogInterface.OnClickListener.class) + .invoke(builder, android.R.string.ok, okClickListener); + builderClazz + .getMethod("setNegativeButton", int.class, DialogInterface.OnClickListener.class) + .invoke(builder, android.R.string.cancel, null); + return (Dialog) builderClazz.getMethod("create").invoke(builder); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the AndroidX compat library is not available. + return null; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private Dialog.OnClickListener setUpDialogView(View dialogView) { + TrackSelectionView selectionView = dialogView.findViewById(R.id.exo_track_selection_view); + selectionView.setAllowMultipleOverrides(allowMultipleOverrides); + selectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); + selectionView.setShowDisableOption(showDisableOption); + if (trackNameProvider != null) { + selectionView.setTrackNameProvider(trackNameProvider); + } + selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null); + return (dialog, which) -> + callback.onTracksSelected(selectionView.getIsDisabled(), selectionView.getOverrides()); + } } diff --git a/library/ui/src/main/proguard-rules.txt b/library/ui/src/main/proguard-rules.txt new file mode 120000 index 0000000000..499fb08b36 --- /dev/null +++ b/library/ui/src/main/proguard-rules.txt @@ -0,0 +1 @@ +../../proguard-rules.txt \ No newline at end of file From 0cd15d9158ab0260e5154092183ea9944f8625ac Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Jul 2020 15:50:29 +0100 Subject: [PATCH 0717/1052] Proactively check listener arguments are non-null PiperOrigin-RevId: 322143359 --- .../android/exoplayer2/demo/DownloadTracker.java | 1 + .../android/exoplayer2/ext/cast/CastPlayer.java | 1 + .../exoplayer2/ext/media2/SettableFuture.java | 2 ++ .../google/android/exoplayer2/ExoPlayerImpl.java | 1 + .../android/exoplayer2/SimpleExoPlayer.java | 15 ++++++++++++--- .../exoplayer2/analytics/AnalyticsCollector.java | 1 + .../exoplayer2/offline/DownloadManager.java | 1 + .../exoplayer2/source/BaseMediaSource.java | 4 ++++ .../android/exoplayer2/source/IcyDataSource.java | 1 + .../exoplayer2/upstream/BandwidthMeter.java | 7 ++++--- .../exoplayer2/upstream/BaseDataSource.java | 2 ++ .../upstream/DefaultBandwidthMeter.java | 2 ++ .../exoplayer2/upstream/DefaultDataSource.java | 1 + .../exoplayer2/upstream/PriorityDataSource.java | 1 + .../exoplayer2/upstream/ResolvingDataSource.java | 3 +++ .../exoplayer2/upstream/StatsDataSource.java | 1 + .../exoplayer2/upstream/TeeDataSource.java | 1 + .../upstream/cache/CacheDataSource.java | 1 + .../exoplayer2/upstream/cache/SimpleCache.java | 2 ++ .../upstream/crypto/AesCipherDataSource.java | 2 ++ .../exoplayer2/source/hls/Aes128DataSource.java | 1 + .../hls/playlist/DefaultHlsPlaylistTracker.java | 1 + .../android/exoplayer2/ui/DefaultTimeBar.java | 1 + .../android/exoplayer2/ui/PlayerControlView.java | 1 + .../exoplayer2/ui/StyledPlayerControlView.java | 1 + 25 files changed, 49 insertions(+), 6 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index 7522ba0499..a57d402d63 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -76,6 +76,7 @@ public class DownloadTracker { } public void addListener(Listener listener) { + checkNotNull(listener); listeners.add(listener); } diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 957001fe74..6e74e8325f 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -291,6 +291,7 @@ public final class CastPlayer extends BasePlayer { @Override public void addListener(EventListener listener) { + Assertions.checkNotNull(listener); listeners.addIfAbsent(new ListenerHolder(listener)); } diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java index 01a30682ae..d8a8c1f5a4 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java @@ -51,6 +51,8 @@ import java.util.concurrent.atomic.AtomicReference; @Override public void addListener(Runnable listener, Executor executor) { + Assertions.checkNotNull(listener); + Assertions.checkNotNull(executor); future.addListener(listener, executor); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index a7479d349b..2cc423be34 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -254,6 +254,7 @@ import java.util.concurrent.TimeoutException; @Override public void addListener(Player.EventListener listener) { + Assertions.checkNotNull(listener); listeners.addIfAbsent(new ListenerHolder(listener)); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index b7fb3a89ea..9607c59a28 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -820,6 +820,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addAudioListener(AudioListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); audioListeners.add(listener); } @@ -970,6 +971,7 @@ public class SimpleExoPlayer extends BasePlayer */ public void addAnalyticsListener(AnalyticsListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); analyticsCollector.addListener(listener); } @@ -1068,6 +1070,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addVideoListener(com.google.android.exoplayer2.video.VideoListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); videoListeners.add(listener); } @@ -1121,7 +1124,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated @SuppressWarnings("deprecation") - public void setVideoListener(VideoListener listener) { + public void setVideoListener(@Nullable VideoListener listener) { videoListeners.clear(); if (listener != null) { addVideoListener(listener); @@ -1144,6 +1147,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addTextOutput(TextOutput listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); textOutputs.add(listener); } @@ -1187,6 +1191,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addMetadataOutput(MetadataOutput listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); metadataOutputs.add(listener); } @@ -1227,7 +1232,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated @SuppressWarnings("deprecation") - public void setVideoDebugListener(VideoRendererEventListener listener) { + public void setVideoDebugListener(@Nullable VideoRendererEventListener listener) { videoDebugListeners.retainAll(Collections.singleton(analyticsCollector)); if (listener != null) { addVideoDebugListener(listener); @@ -1240,6 +1245,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated public void addVideoDebugListener(VideoRendererEventListener listener) { + Assertions.checkNotNull(listener); videoDebugListeners.add(listener); } @@ -1258,7 +1264,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated @SuppressWarnings("deprecation") - public void setAudioDebugListener(AudioRendererEventListener listener) { + public void setAudioDebugListener(@Nullable AudioRendererEventListener listener) { audioDebugListeners.retainAll(Collections.singleton(analyticsCollector)); if (listener != null) { addAudioDebugListener(listener); @@ -1271,6 +1277,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated public void addAudioDebugListener(AudioRendererEventListener listener) { + Assertions.checkNotNull(listener); audioDebugListeners.add(listener); } @@ -1298,6 +1305,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addListener(Player.EventListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); player.addListener(listener); } @@ -1866,6 +1874,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addDeviceListener(DeviceListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); deviceListeners.add(listener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 694f2bc8c1..743b415f09 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -101,6 +101,7 @@ public class AnalyticsCollector * @param listener The listener to add. */ public void addListener(AnalyticsListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 6b12ab3759..edb9995b60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -324,6 +324,7 @@ public final class DownloadManager { * @param listener The listener to be added. */ public void addListener(Listener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java index 04b8ad1f83..96ef4b0c6d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java @@ -162,6 +162,8 @@ public abstract class BaseMediaSource implements MediaSource { @Override public final void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); eventDispatcher.addEventListener(handler, eventListener); } @@ -172,6 +174,8 @@ public abstract class BaseMediaSource implements MediaSource { @Override public final void addDrmEventListener(Handler handler, DrmSessionEventListener eventListener) { + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); drmEventDispatcher.addEventListener(handler, eventListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java index 84d2902c53..04fe67b119 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java @@ -67,6 +67,7 @@ import java.util.Map; @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java index 8fefb50a96..d520fcfa60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java @@ -56,10 +56,11 @@ public interface BandwidthMeter { } /** Adds a listener to the event dispatcher. */ - public void addListener(Handler handler, BandwidthMeter.EventListener eventListener) { - Assertions.checkArgument(handler != null && eventListener != null); + public void addListener(Handler eventHandler, BandwidthMeter.EventListener eventListener) { + Assertions.checkNotNull(eventHandler); + Assertions.checkNotNull(eventListener); removeListener(eventListener); - listeners.add(new HandlerAndListener(handler, eventListener)); + listeners.add(new HandlerAndListener(eventHandler, eventListener)); } /** Removes a listener from the event dispatcher. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java index 80687db31f..ce6243eda0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import androidx.annotation.Nullable; @@ -47,6 +48,7 @@ public abstract class BaseDataSource implements DataSource { @Override public final void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); if (!listeners.contains(transferListener)) { listeners.add(transferListener); listenerCount++; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index 66aec96e89..2d62bdbf95 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -332,6 +332,8 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList @Override public void addEventListener(Handler eventHandler, EventListener eventListener) { + Assertions.checkNotNull(eventHandler); + Assertions.checkNotNull(eventListener); eventDispatcher.addListener(eventHandler, eventListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java index 98026c4677..afef3e6761 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java @@ -135,6 +135,7 @@ public final class DefaultDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); baseDataSource.addTransferListener(transferListener); transferListeners.add(transferListener); maybeAddListenerToDataSource(fileDataSource, transferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java index 767b6d78a3..e52e1db376 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java @@ -55,6 +55,7 @@ public final class PriorityDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java index f5fb67e40e..958780cbc3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import androidx.annotation.Nullable; import java.io.IOException; @@ -95,6 +97,7 @@ public final class ResolvingDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); upstreamDataSource.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java index 6cdc381ba2..4340169f45 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java @@ -72,6 +72,7 @@ public final class StatsDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); dataSource.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java index f56f19a6ca..689273d388 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java @@ -45,6 +45,7 @@ public final class TeeDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index 7398ff58a2..e1ec63161a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -541,6 +541,7 @@ public final class CacheDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); cacheReadDataSource.addTransferListener(transferListener); upstreamDataSource.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 17655fa312..29c09ff486 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -309,6 +309,8 @@ public final class SimpleCache implements Cache { @Override public synchronized NavigableSet addListener(String key, Listener listener) { Assertions.checkState(!released); + Assertions.checkNotNull(key); + Assertions.checkNotNull(listener); ArrayList listenersForKey = listeners.get(key); if (listenersForKey == null) { listenersForKey = new ArrayList<>(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java index 665a47191e..5abe42b937 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream.crypto; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import android.net.Uri; @@ -45,6 +46,7 @@ public final class AesCipherDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java index fe70298dc8..11d68b1c08 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java @@ -66,6 +66,7 @@ import javax.crypto.spec.SecretKeySpec; @Override public final void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index 02d2718dec..1ad75dac1e 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -161,6 +161,7 @@ public final class DefaultHlsPlaylistTracker @Override public void addListener(PlaylistEventListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 44c0035278..24d890134a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -450,6 +450,7 @@ public class DefaultTimeBar extends View implements TimeBar { @Override public void addListener(OnScrubListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java index 4b9de374c3..62e9094cf0 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java @@ -586,6 +586,7 @@ public class PlayerControlView extends FrameLayout { * @param listener The listener to be notified about visibility changes. */ public void addVisibilityListener(VisibilityListener listener) { + Assertions.checkNotNull(listener); visibilityListeners.add(listener); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java index a33a508d89..0d51f94661 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -790,6 +790,7 @@ public class StyledPlayerControlView extends FrameLayout { * @param listener The listener to be notified about visibility changes. */ public void addVisibilityListener(VisibilityListener listener) { + Assertions.checkNotNull(listener); visibilityListeners.add(listener); } From df1536ab24ced76563f9ecd8ff1fed0912a4ebf5 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Jul 2020 15:53:26 +0100 Subject: [PATCH 0718/1052] Migrate WorkManagerScheduler to non-deprecated WorkManager.getInstance PiperOrigin-RevId: 322143769 --- .../ext/workmanager/WorkManagerScheduler.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java index 6ecace6fa5..ff9335ad84 100644 --- a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java +++ b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java @@ -46,16 +46,27 @@ public final class WorkManagerScheduler implements Scheduler { | Requirements.DEVICE_CHARGING | Requirements.DEVICE_STORAGE_NOT_LOW; + private final WorkManager workManager; private final String workName; + /** @deprecated Call {@link #WorkManagerScheduler(Context, String)} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public WorkManagerScheduler(String workName) { + this.workName = workName; + workManager = WorkManager.getInstance(); + } + /** + * @param context A context. * @param workName A name for work scheduled by this instance. If the same name was used by a * previous instance, anything scheduled by the previous instance will be canceled by this * instance if {@link #schedule(Requirements, String, String)} or {@link #cancel()} are * called. */ - public WorkManagerScheduler(String workName) { + public WorkManagerScheduler(Context context, String workName) { this.workName = workName; + workManager = WorkManager.getInstance(context.getApplicationContext()); } @Override @@ -63,13 +74,13 @@ public final class WorkManagerScheduler implements Scheduler { Constraints constraints = buildConstraints(requirements); Data inputData = buildInputData(requirements, servicePackage, serviceAction); OneTimeWorkRequest workRequest = buildWorkRequest(constraints, inputData); - WorkManager.getInstance().enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest); + workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest); return true; } @Override public boolean cancel() { - WorkManager.getInstance().cancelUniqueWork(workName); + workManager.cancelUniqueWork(workName); return true; } From 7c995a3cfa7f827323e918134835f79cd9fffa4c Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 20 Jul 2020 16:04:20 +0100 Subject: [PATCH 0719/1052] Fix javaDoc of onMediaItemTransition PiperOrigin-RevId: 322145517 --- .../main/java/com/google/android/exoplayer2/Player.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index edfe1197e3..d9575420a0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -471,9 +471,13 @@ public interface Player { Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {} /** - * Called when playback transitions to a different media item. + * Called when playback transitions to a media item or starts repeating a media item according + * to the current {@link #getRepeatMode() repeat mode}. * - * @param mediaItem The {@link MediaItem}. May be null if the timeline becomes empty. + *

        Note that this callback is also called when the playlist becomes non-empty or empty as a + * consequence of a playlist change. + * + * @param mediaItem The {@link MediaItem}. May be null if the playlist becomes empty. * @param reason The reason for the transition. */ default void onMediaItemTransition( From 302b5f2ba40791b8aededd7f35135ab475430553 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Jul 2020 16:37:22 +0100 Subject: [PATCH 0720/1052] Remove unnecessary use of Robolectric LEGACY looper mode CacheDataSourceTest2 works fine in PAUSED mode as well. PiperOrigin-RevId: 322150471 --- .../exoplayer2/upstream/cache/CacheDataSourceTest2.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java index e55d16541a..e6b44e9aa8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.common.truth.Truth.assertThat; import static java.util.Arrays.copyOf; import static java.util.Arrays.copyOfRange; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.content.Context; import android.net.Uri; @@ -40,10 +39,8 @@ import java.io.IOException; import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Additional tests for {@link CacheDataSource}. */ -@LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) public final class CacheDataSourceTest2 { From 576ef821914aba1dc1b56a2fc77f076fbc612744 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Jul 2020 16:54:03 +0100 Subject: [PATCH 0721/1052] Remove explicit use of Robolectric PAUSED looper mode It's now the default everywhere, so there's no need to specify it explicitly. PiperOrigin-RevId: 322153319 --- .../android/exoplayer2/ext/cronet/CronetDataSourceTest.java | 3 --- .../test/java/com/google/android/exoplayer2/ExoPlayerTest.java | 2 -- .../android/exoplayer2/analytics/AnalyticsCollectorTest.java | 3 --- .../android/exoplayer2/drm/OfflineLicenseHelperTest.java | 2 -- .../mediacodec/AsynchronousMediaCodecAdapterTest.java | 3 --- .../google/android/exoplayer2/offline/DownloadHelperTest.java | 2 -- .../google/android/exoplayer2/offline/DownloadManagerTest.java | 3 --- .../android/exoplayer2/source/ClippingMediaSourceTest.java | 3 --- .../exoplayer2/source/ConcatenatingMediaSourceTest.java | 2 -- .../android/exoplayer2/source/LoopingMediaSourceTest.java | 2 -- .../android/exoplayer2/source/MergingMediaPeriodTest.java | 2 -- .../android/exoplayer2/source/MergingMediaSourceTest.java | 2 -- .../android/exoplayer2/source/ProgressiveMediaPeriodTest.java | 2 -- .../android/exoplayer2/source/ads/AdsMediaSourceTest.java | 3 --- .../android/exoplayer2/video/DecoderVideoRendererTest.java | 2 -- .../android/exoplayer2/source/dash/DashMediaPeriodTest.java | 2 -- .../source/dash/offline/DownloadManagerDashTest.java | 2 -- .../source/dash/offline/DownloadServiceDashTest.java | 2 -- .../android/exoplayer2/source/hls/HlsMediaPeriodTest.java | 2 -- .../exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java | 2 -- .../com/google/android/exoplayer2/testutil/FakeClockTest.java | 2 -- 21 files changed, 48 deletions(-) diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java index 35d06bebff..6884c820c5 100644 --- a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java +++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -64,13 +64,10 @@ import org.junit.runner.RunWith; import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; import org.robolectric.shadows.ShadowLooper; /** Tests for {@link CronetDataSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public final class CronetDataSourceTest { private static final int TEST_CONNECT_TIMEOUT_MS = 100; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 25f94da208..339351fa8f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -108,12 +108,10 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowAudioManager; /** Unit test for {@link ExoPlayer}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class ExoPlayerTest { private static final String TAG = "ExoPlayerTest"; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 03503defc8..9166db6e99 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -70,12 +70,9 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; /** Integration test for {@link AnalyticsCollector}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public final class AnalyticsCollectorTest { private static final String TAG = "AnalyticsCollectorTest"; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java index d57b3bb6e8..115bf4cabe 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java @@ -32,11 +32,9 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.robolectric.annotation.LooperMode; /** Tests {@link OfflineLicenseHelper}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class OfflineLicenseHelperTest { private OfflineLicenseHelper offlineLicenseHelper; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java index ea83e17905..dc32ce65a1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java @@ -19,7 +19,6 @@ package com.google.android.exoplayer2.mediacodec; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.PAUSED; import android.media.MediaCodec; import android.media.MediaFormat; @@ -32,11 +31,9 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link AsynchronousMediaCodecAdapter}. */ -@LooperMode(PAUSED) @RunWith(AndroidJUnit4.class) public class AsynchronousMediaCodecAdapterTest { private AsynchronousMediaCodecAdapter adapter; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index 73917606c5..ae10f08a39 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -52,11 +52,9 @@ import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link DownloadHelper}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class DownloadHelperTest { private static final Object TEST_MANIFEST = new Object(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java index a9a6d3888c..de1c35ed49 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java @@ -41,13 +41,10 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; import org.robolectric.shadows.ShadowLog; /** Tests {@link DownloadManager}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public class DownloadManagerTest { /** Timeout to use when blocking on conditions that we expect to become unblocked. */ diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index 4a7dea9315..8fce3b25ac 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -45,12 +45,9 @@ import java.io.IOException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; /** Unit tests for {@link ClippingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public final class ClippingMediaSourceTest { private static final long TEST_PERIOD_DURATION_US = 1_000_000; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index abf0296a6e..99c427ccf2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -43,11 +43,9 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link ConcatenatingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class ConcatenatingMediaSourceTest { private ConcatenatingMediaSource mediaSource; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java index f938ffe370..9c883a149a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java @@ -28,11 +28,9 @@ import java.io.IOException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link LoopingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class LoopingMediaSourceTest { private FakeTimeline multiWindowTimeline; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java index 12f52cf2c2..e28af160c3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java @@ -36,11 +36,9 @@ import java.util.concurrent.CountDownLatch; import org.checkerframework.checker.nullness.compatqual.NullableType; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit test for {@link MergingMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class MergingMediaPeriodTest { private static final Format childFormat11 = new Format.Builder().setId("1_1").build(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java index 4d91b7a34c..c66a5cff74 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java @@ -29,11 +29,9 @@ import com.google.android.exoplayer2.testutil.MediaSourceTestRunner; import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link MergingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class MergingMediaSourceTest { @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java index 1360f66a3e..8947c97955 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java @@ -33,11 +33,9 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit test for {@link ProgressiveMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class ProgressiveMediaPeriodTest { @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java index b0c7180d87..8395fcb1f4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -22,7 +22,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.PAUSED; import android.net.Uri; import android.os.Looper; @@ -47,11 +46,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link AdsMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(PAUSED) public final class AdsMediaSourceTest { private static final long PREROLL_AD_DURATION_US = 10 * C.MICROS_PER_SECOND; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java index b9dc866371..8632f4baab 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java @@ -52,11 +52,9 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowLooper; /** Unit test for {@link DecoderVideoRenderer}. */ -@LooperMode(LooperMode.Mode.PAUSED) @RunWith(AndroidJUnit4.class) public final class DecoderVideoRendererTest { @Rule public final MockitoRule mockito = MockitoJUnit.rule(); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java index de680ad220..3d6bd3d6df 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java @@ -48,11 +48,9 @@ import java.util.Collections; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link DashMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class DashMediaPeriodTest { @Test diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index cdee3becdb..307e06f821 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -55,12 +55,10 @@ import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.MockitoAnnotations; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowLog; /** Tests {@link DownloadManager}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class DownloadManagerDashTest { private static final int ASSERT_TRUE_TIMEOUT_MS = 5000; diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index 8dc88bffe5..3d0db421cf 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -57,11 +57,9 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link DownloadService}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class DownloadServiceDashTest { private SimpleCache cache; diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java index 36680e9a32..a6c42f9754 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java @@ -44,11 +44,9 @@ import java.util.Collections; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit test for {@link HlsMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class HlsMediaPeriodTest { @Test diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java index 7042af8aa6..81648706c4 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java @@ -37,11 +37,9 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link SsMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class SsMediaPeriodTest { @Test diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java index b0511f8aba..b697f23d5d 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java @@ -26,11 +26,9 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit test for {@link FakeClock}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class FakeClockTest { private static final long TIMEOUT_MS = 10_000; From 6eb706002afd3cf0168b686e9f1a1cf7deaa136b Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Jul 2020 16:58:56 +0100 Subject: [PATCH 0722/1052] Migrate to Robolectric PAUSED looper mode: AudioFocusManagerTest PiperOrigin-RevId: 322154193 --- .../exoplayer2/AudioFocusManagerTest.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java index 2b9f476c61..b13b7fe5b1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java @@ -20,8 +20,8 @@ import static com.google.android.exoplayer2.AudioFocusManager.PLAYER_COMMAND_PLA import static com.google.android.exoplayer2.AudioFocusManager.PLAYER_COMMAND_WAIT_FOR_CALLBACK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import static org.robolectric.Shadows.shadowOf; import static org.robolectric.annotation.Config.TARGET_SDK; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.content.Context; import android.media.AudioFocusRequest; @@ -37,11 +37,9 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Shadows; import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowAudioManager; /** Unit tests for {@link AudioFocusManager}. */ -@LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) public class AudioFocusManagerTest { private static final int NO_COMMAND_RECEIVED = ~PLAYER_COMMAND_WAIT_FOR_CALLBACK; @@ -231,8 +229,9 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); - assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); // Focus should be re-requested, rather than staying in a state of transient ducking. This // should restore the volume to 1.0. See https://github.com/google/ExoPlayer/issues/7182 for // context. @@ -254,6 +253,8 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); // Configure the manager to no longer handle focus. @@ -354,6 +355,8 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); audioFocusManager.release(); @@ -374,10 +377,14 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(NO_COMMAND_RECEIVED); + audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isEqualTo(1.0f); } @@ -399,9 +406,14 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_WAIT_FOR_CALLBACK); assertThat(testPlayerControl.lastVolumeMultiplier).isEqualTo(1.0f); + audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_PLAY_WHEN_READY); } @@ -415,6 +427,8 @@ public class AudioFocusManagerTest { .isEqualTo(PLAYER_COMMAND_PLAY_WHEN_READY); audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isEqualTo(1.0f); assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_WAIT_FOR_CALLBACK); } @@ -433,6 +447,8 @@ public class AudioFocusManagerTest { ShadowAudioManager.AudioFocusRequest request = Shadows.shadowOf(audioManager).getLastAudioFocusRequest(); request.listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_DO_NOT_PLAY); assertThat(Shadows.shadowOf(audioManager).getLastAbandonedAudioFocusListener()) .isEqualTo(request.listener); @@ -450,6 +466,8 @@ public class AudioFocusManagerTest { assertThat(Shadows.shadowOf(audioManager).getLastAbandonedAudioFocusRequest()).isNull(); audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_DO_NOT_PLAY); assertThat(Shadows.shadowOf(audioManager).getLastAbandonedAudioFocusRequest()) .isEqualTo(Shadows.shadowOf(audioManager).getLastAudioFocusRequest().audioFocusRequest); From 97cc355bafc652240724d85366fc770637deaaa3 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 20 Jul 2020 17:11:49 +0100 Subject: [PATCH 0723/1052] Fix bug in downloader proguard config. The nested class names need to use a $ sign. PiperOrigin-RevId: 322156862 --- library/core/proguard-rules.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 4fcfeb7162..128f227896 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -51,15 +51,15 @@ # Constructors accessed via reflection in DefaultDownloaderFactory -dontnote com.google.android.exoplayer2.source.dash.offline.DashDownloader -keepclassmembers class com.google.android.exoplayer2.source.dash.offline.DashDownloader { - (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); + (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory, java.util.concurrent.Executor); } -dontnote com.google.android.exoplayer2.source.hls.offline.HlsDownloader -keepclassmembers class com.google.android.exoplayer2.source.hls.offline.HlsDownloader { - (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); + (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory, java.util.concurrent.Executor); } -dontnote com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader -keepclassmembers class com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader { - (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); + (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory, java.util.concurrent.Executor); } # Constructors accessed via reflection in DefaultMediaSourceFactory From 9594aa45ff316a24d9cab2859fd3bd950e4bfdaa Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 20 Jul 2020 17:47:29 +0100 Subject: [PATCH 0724/1052] Move functionality from DemoApplication to DemoUtil https://developer.android.com/reference/android/app/Application recommends against subclassing application. PiperOrigin-RevId: 322163812 --- demos/main/build.gradle | 1 + demos/main/src/main/AndroidManifest.xml | 2 +- .../exoplayer2/demo/DemoDownloadService.java | 18 ++- .../{DemoApplication.java => DemoUtil.java} | 142 ++++++++++-------- .../exoplayer2/demo/PlayerActivity.java | 6 +- .../demo/SampleChooserActivity.java | 9 +- .../ui/DownloadNotificationHelper.java | 28 +++- .../ui/DownloadNotificationUtil.java | 6 +- 8 files changed, 123 insertions(+), 89 deletions(-) rename demos/main/src/main/java/com/google/android/exoplayer2/demo/{DemoApplication.java => DemoUtil.java} (51%) diff --git a/demos/main/build.gradle b/demos/main/build.gradle index abf5a471b8..e2e485b76a 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -63,6 +63,7 @@ android { } dependencies { + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion implementation 'androidx.multidex:multidex:' + androidxMultidexVersion diff --git a/demos/main/src/main/AndroidManifest.xml b/demos/main/src/main/AndroidManifest.xml index 0240a377ac..d0bf623749 100644 --- a/demos/main/src/main/AndroidManifest.xml +++ b/demos/main/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ android:largeHeap="true" android:allowBackup="false" android:requestLegacyExternalStorage="true" - android:name="com.google.android.exoplayer2.demo.DemoApplication" + android:name="androidx.multidex.MultiDexApplication" tools:ignore="UnusedAttribute"> downloads) { - return ((DemoApplication) getApplication()) - .getDownloadNotificationHelper() + return DemoUtil.getDownloadNotificationHelper(/* context= */ this) .buildProgressNotification( - R.drawable.ic_download, /* contentIntent= */ null, /* message= */ null, downloads); + /* context= */ this, + R.drawable.ic_download, + /* contentIntent= */ null, + /* message= */ null, + downloads); } /** @@ -101,12 +103,14 @@ public class DemoDownloadService extends DownloadService { if (download.state == Download.STATE_COMPLETED) { notification = notificationHelper.buildDownloadCompletedNotification( + context, R.drawable.ic_download_done, /* contentIntent= */ null, Util.fromUtf8Bytes(download.request.data)); } else if (download.state == Download.STATE_FAILED) { notification = notificationHelper.buildDownloadFailedNotification( + context, R.drawable.ic_download_done, /* contentIntent= */ null, Util.fromUtf8Bytes(download.request.data)); diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java similarity index 51% rename from demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java rename to demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java index f4205efbb4..ed7a71b156 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.demo; -import androidx.multidex.MultiDexApplication; +import android.content.Context; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.database.DatabaseProvider; @@ -38,53 +38,34 @@ import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; import java.util.concurrent.Executors; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -/** - * Placeholder application to facilitate overriding Application methods for debugging and testing. - */ -public class DemoApplication extends MultiDexApplication { +/** Utility methods for the demo app. */ +public final class DemoUtil { public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; - private static final String TAG = "DemoApplication"; + private static final String TAG = "DemoUtil"; private static final String DOWNLOAD_ACTION_FILE = "actions"; private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; - private HttpDataSource.Factory httpDataSourceFactory; - private DatabaseProvider databaseProvider; - private File downloadDirectory; - private Cache downloadCache; - private DownloadManager downloadManager; - private DownloadTracker downloadTracker; - private DownloadNotificationHelper downloadNotificationHelper; - - @Override - public void onCreate() { - super.onCreate(); - CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(/* context= */ this); - String userAgent = Util.getUserAgent(this, "ExoPlayerDemo"); - httpDataSourceFactory = - new CronetDataSourceFactory( - cronetEngineWrapper, - Executors.newSingleThreadExecutor(), - /* transferListener= */ null, - userAgent); - } - - /** Returns a {@link DataSource.Factory}. */ - public DataSource.Factory buildDataSourceFactory() { - DefaultDataSourceFactory upstreamFactory = - new DefaultDataSourceFactory(/* context= */ this, httpDataSourceFactory); - return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache()); - } + private static DataSource.@MonotonicNonNull Factory dataSourceFactory; + private static HttpDataSource.@MonotonicNonNull Factory httpDataSourceFactory; + private static @MonotonicNonNull DatabaseProvider databaseProvider; + private static @MonotonicNonNull File downloadDirectory; + private static @MonotonicNonNull Cache downloadCache; + private static @MonotonicNonNull DownloadManager downloadManager; + private static @MonotonicNonNull DownloadTracker downloadTracker; + private static @MonotonicNonNull DownloadNotificationHelper downloadNotificationHelper; /** Returns whether extension renderers should be used. */ - public boolean useExtensionRenderers() { + public static boolean useExtensionRenderers() { return "withDecoderExtensions".equals(BuildConfig.FLAVOR); } - public RenderersFactory buildRenderersFactory(boolean preferExtensionRenderer) { + public static RenderersFactory buildRenderersFactory( + Context context, boolean preferExtensionRenderer) { @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode = useExtensionRenderers() @@ -92,61 +73,96 @@ public class DemoApplication extends MultiDexApplication { ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; - return new DefaultRenderersFactory(/* context= */ this) + return new DefaultRenderersFactory(context.getApplicationContext()) .setExtensionRendererMode(extensionRendererMode); } - public DownloadNotificationHelper getDownloadNotificationHelper() { + public static synchronized HttpDataSource.Factory getHttpDataSourceFactory(Context context) { + if (httpDataSourceFactory == null) { + context = context.getApplicationContext(); + CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(context); + String userAgent = Util.getUserAgent(context, "ExoPlayerDemo"); + httpDataSourceFactory = + new CronetDataSourceFactory( + cronetEngineWrapper, + Executors.newSingleThreadExecutor(), + /* transferListener= */ null, + userAgent); + } + return httpDataSourceFactory; + } + + /** Returns a {@link DataSource.Factory}. */ + public static synchronized DataSource.Factory buildDataSourceFactory(Context context) { + if (dataSourceFactory == null) { + context = context.getApplicationContext(); + DefaultDataSourceFactory upstreamFactory = + new DefaultDataSourceFactory(context, getHttpDataSourceFactory(context)); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } + return dataSourceFactory; + } + + public static synchronized DownloadNotificationHelper getDownloadNotificationHelper( + Context context) { if (downloadNotificationHelper == null) { downloadNotificationHelper = - new DownloadNotificationHelper(this, DOWNLOAD_NOTIFICATION_CHANNEL_ID); + new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); } return downloadNotificationHelper; } - public DownloadManager getDownloadManager() { - initDownloadManager(); + public static synchronized DownloadManager getDownloadManager(Context context) { + ensureDownloadManagerInitialized(context); return downloadManager; } - public DownloadTracker getDownloadTracker() { - initDownloadManager(); + public static synchronized DownloadTracker getDownloadTracker(Context context) { + ensureDownloadManagerInitialized(context); return downloadTracker; } - private synchronized Cache getDownloadCache() { + private static synchronized Cache getDownloadCache(Context context) { if (downloadCache == null) { - File downloadContentDirectory = new File(getDownloadDirectory(), DOWNLOAD_CONTENT_DIRECTORY); + File downloadContentDirectory = + new File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY); downloadCache = - new SimpleCache(downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider()); + new SimpleCache( + downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider(context)); } return downloadCache; } - private synchronized void initDownloadManager() { + private static synchronized void ensureDownloadManagerInitialized(Context context) { if (downloadManager == null) { - DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(getDatabaseProvider()); + DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(getDatabaseProvider(context)); upgradeActionFile( - DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false); + context, DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false); upgradeActionFile( - DOWNLOAD_TRACKER_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ true); + context, + DOWNLOAD_TRACKER_ACTION_FILE, + downloadIndex, + /* addNewDownloadsAsCompleted= */ true); downloadManager = new DownloadManager( - /* context= */ this, - getDatabaseProvider(), - getDownloadCache(), - httpDataSourceFactory, + context, + getDatabaseProvider(context), + getDownloadCache(context), + getHttpDataSourceFactory(context), Executors.newFixedThreadPool(/* nThreads= */ 6)); downloadTracker = - new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadManager); + new DownloadTracker(context, buildDataSourceFactory(context), downloadManager); } } - private void upgradeActionFile( - String fileName, DefaultDownloadIndex downloadIndex, boolean addNewDownloadsAsCompleted) { + private static synchronized void upgradeActionFile( + Context context, + String fileName, + DefaultDownloadIndex downloadIndex, + boolean addNewDownloadsAsCompleted) { try { ActionFileUpgradeUtil.upgradeAndDelete( - new File(getDownloadDirectory(), fileName), + new File(getDownloadDirectory(context), fileName), /* downloadIdProvider= */ null, downloadIndex, /* deleteOnFailure= */ true, @@ -156,18 +172,18 @@ public class DemoApplication extends MultiDexApplication { } } - private DatabaseProvider getDatabaseProvider() { + private static synchronized DatabaseProvider getDatabaseProvider(Context context) { if (databaseProvider == null) { - databaseProvider = new ExoDatabaseProvider(this); + databaseProvider = new ExoDatabaseProvider(context); } return databaseProvider; } - private File getDownloadDirectory() { + private static synchronized File getDownloadDirectory(Context context) { if (downloadDirectory == null) { - downloadDirectory = getExternalFilesDir(null); + downloadDirectory = context.getExternalFilesDir(/* type= */ null); if (downloadDirectory == null) { - downloadDirectory = getFilesDir(); + downloadDirectory = context.getFilesDir(); } } return downloadDirectory; @@ -181,4 +197,6 @@ public class DemoApplication extends MultiDexApplication { .setCacheWriteDataSinkFactory(null) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); } + + private DemoUtil() {} } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 3f80390618..8ddecdad59 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -315,7 +315,7 @@ public class PlayerActivity extends AppCompatActivity boolean preferExtensionDecoders = intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false); RenderersFactory renderersFactory = - ((DemoApplication) getApplication()).buildRenderersFactory(preferExtensionDecoders); + DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); trackSelector = new DefaultTrackSelector(/* context= */ this, trackSelectionFactory); trackSelector.setParameters(trackSelectorParameters); @@ -357,7 +357,7 @@ public class PlayerActivity extends AppCompatActivity List mediaItems = IntentUtil.createMediaItemsFromIntent( - intent, ((DemoApplication) getApplication()).getDownloadTracker()); + intent, DemoUtil.getDownloadTracker(/* context= */ this)); boolean hasAds = false; for (int i = 0; i < mediaItems.size(); i++) { MediaItem mediaItem = mediaItems.get(i); @@ -439,7 +439,7 @@ public class PlayerActivity extends AppCompatActivity /** Returns a new DataSource factory. */ protected DataSource.Factory buildDataSourceFactory() { - return ((DemoApplication) getApplication()).buildDataSourceFactory(); + return DemoUtil.buildDataSourceFactory(/* context= */ this); } // User controls diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 036ea47f7e..ae79fb84f5 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -114,9 +114,8 @@ public class SampleChooserActivity extends AppCompatActivity Arrays.sort(uris); } - DemoApplication application = (DemoApplication) getApplication(); - useExtensionRenderers = application.useExtensionRenderers(); - downloadTracker = application.getDownloadTracker(); + useExtensionRenderers = DemoUtil.useExtensionRenderers(); + downloadTracker = DemoUtil.getDownloadTracker(/* context= */ this); loadSample(); // Start the download service if it should be running but it's not currently. @@ -247,8 +246,8 @@ public class SampleChooserActivity extends AppCompatActivity .show(); } else { RenderersFactory renderersFactory = - ((DemoApplication) getApplication()) - .buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem)); + DemoUtil.buildRenderersFactory( + /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); downloadTracker.toggleDownload( getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java index 178cd44dd3..83da4d54a8 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java @@ -31,7 +31,6 @@ public final class DownloadNotificationHelper { private static final @StringRes int NULL_STRING_ID = 0; - private final Context context; private final NotificationCompat.Builder notificationBuilder; /** @@ -39,14 +38,14 @@ public final class DownloadNotificationHelper { * @param channelId The id of the notification channel to use. */ public DownloadNotificationHelper(Context context, String channelId) { - context = context.getApplicationContext(); - this.context = context; - this.notificationBuilder = new NotificationCompat.Builder(context, channelId); + this.notificationBuilder = + new NotificationCompat.Builder(context.getApplicationContext(), channelId); } /** * Returns a progress notification for the given downloads. * + * @param context A context. * @param smallIcon A small icon for the notification. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. @@ -54,6 +53,7 @@ public final class DownloadNotificationHelper { * @return The notification. */ public Notification buildProgressNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, @@ -95,6 +95,7 @@ public final class DownloadNotificationHelper { indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes; } return buildNotification( + context, smallIcon, contentIntent, message, @@ -109,37 +110,47 @@ public final class DownloadNotificationHelper { /** * Returns a notification for a completed download. * + * @param context A context. * @param smallIcon A small icon for the notifications. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @return The notification. */ public Notification buildDownloadCompletedNotification( - @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) { + Context context, + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message) { int titleStringId = R.string.exo_download_completed; - return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId); + return buildEndStateNotification(context, smallIcon, contentIntent, message, titleStringId); } /** * Returns a notification for a failed download. * + * @param context A context. * @param smallIcon A small icon for the notifications. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @return The notification. */ public Notification buildDownloadFailedNotification( - @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) { + Context context, + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message) { @StringRes int titleStringId = R.string.exo_download_failed; - return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId); + return buildEndStateNotification(context, smallIcon, contentIntent, message, titleStringId); } private Notification buildEndStateNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, @StringRes int titleStringId) { return buildNotification( + context, smallIcon, contentIntent, message, @@ -152,6 +163,7 @@ public final class DownloadNotificationHelper { } private Notification buildNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java index 223a97f69c..8c03dbea42 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java @@ -52,7 +52,7 @@ public final class DownloadNotificationUtil { @Nullable String message, List downloads) { return new DownloadNotificationHelper(context, channelId) - .buildProgressNotification(smallIcon, contentIntent, message, downloads); + .buildProgressNotification(context, smallIcon, contentIntent, message, downloads); } /** @@ -72,7 +72,7 @@ public final class DownloadNotificationUtil { @Nullable PendingIntent contentIntent, @Nullable String message) { return new DownloadNotificationHelper(context, channelId) - .buildDownloadCompletedNotification(smallIcon, contentIntent, message); + .buildDownloadCompletedNotification(context, smallIcon, contentIntent, message); } /** @@ -92,6 +92,6 @@ public final class DownloadNotificationUtil { @Nullable PendingIntent contentIntent, @Nullable String message) { return new DownloadNotificationHelper(context, channelId) - .buildDownloadFailedNotification(smallIcon, contentIntent, message); + .buildDownloadFailedNotification(context, smallIcon, contentIntent, message); } } From aed5aca3dde5c52f3649033655ae0901f811c082 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 20 Jul 2020 18:01:37 +0100 Subject: [PATCH 0725/1052] ActionFileUpgradeUtil: add more tests action files Add test action files for DASH, HLS, SmoothStreaming and Progressive. PiperOrigin-RevId: 322166875 --- .../offline/ActionFileUpgradeUtilTest.java | 174 +++++++++++++++--- ...action_file_for_download_index_upgrade.exi | Bin 161 -> 0 bytes ...n_file_for_download_index_upgrade_dash.exi | Bin 0 -> 146 bytes ...on_file_for_download_index_upgrade_hls.exi | Bin 0 -> 146 bytes ...for_download_index_upgrade_progressive.exi | Bin 0 -> 140 bytes ...ion_file_for_download_index_upgrade_ss.exi | Bin 0 -> 154 bytes 6 files changed, 145 insertions(+), 29 deletions(-) delete mode 100644 testdata/src/test/assets/offline/action_file_for_download_index_upgrade.exi create mode 100644 testdata/src/test/assets/offline/action_file_for_download_index_upgrade_dash.exi create mode 100644 testdata/src/test/assets/offline/action_file_for_download_index_upgrade_hls.exi create mode 100644 testdata/src/test/assets/offline/action_file_for_download_index_upgrade_progressive.exi create mode 100644 testdata/src/test/assets/offline/action_file_for_download_index_upgrade_ss.exi diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java index c77dca90ee..df45f88401 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java @@ -24,11 +24,10 @@ import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.util.Arrays; -import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -58,38 +57,32 @@ public class ActionFileUpgradeUtilTest { } @Test - public void upgradeAndDelete_createsDownloads() throws IOException { - // Copy the test asset to a file. + public void upgradeAndDelete_progressiveActionFile_createsDownloads() throws IOException { byte[] actionFileBytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), - "offline/action_file_for_download_index_upgrade.exi"); + "offline/action_file_for_download_index_upgrade_progressive.exi"); try (FileOutputStream output = new FileOutputStream(tempFile)) { output.write(actionFileBytes); } - - StreamKey expectedStreamKey1 = - new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5); - StreamKey expectedStreamKey2 = - new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest expectedRequest1 = new DownloadRequest( - /* id= */ "key123", - Uri.parse("https://www.test.com/download1"), + /* id= */ "http://www.test.com/1/video.mp4", + Uri.parse("http://www.test.com/1/video.mp4"), /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, - asList(expectedStreamKey1), + /* streamKeys= */ ImmutableList.of(), /* keySetId= */ null, - /* customCacheKey= */ "key123", - /* data= */ new byte[] {1, 2, 3, 4}); + /* customCacheKey= */ null, + /* data= */ null); DownloadRequest expectedRequest2 = new DownloadRequest( - /* id= */ "key234", - Uri.parse("https://www.test.com/download2"), + /* id= */ "customCacheKey", + Uri.parse("http://www.test.com/2/video.mp4"), /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, - asList(expectedStreamKey2), + /* streamKeys= */ ImmutableList.of(), /* keySetId= */ null, - /* customCacheKey= */ "key234", - new byte[] {5, 4, 3, 2, 1}); + /* customCacheKey= */ "customCacheKey", + /* data= */ new byte[] {0, 1, 2, 3}); ActionFileUpgradeUtil.upgradeAndDelete( tempFile, @@ -98,6 +91,133 @@ public class ActionFileUpgradeUtilTest { /* deleteOnFailure= */ true, /* addNewDownloadsAsCompleted= */ false); + assertThat(tempFile.exists()).isFalse(); + assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); + assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); + } + + @Test + public void upgradeAndDelete_dashActionFile_createsDownloads() throws IOException { + byte[] actionFileBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "offline/action_file_for_download_index_upgrade_dash.exi"); + try (FileOutputStream output = new FileOutputStream(tempFile)) { + output.write(actionFileBytes); + } + DownloadRequest expectedRequest1 = + new DownloadRequest( + /* id= */ "http://www.test.com/1/manifest.mpd", + Uri.parse("http://www.test.com/1/manifest.mpd"), + MimeTypes.APPLICATION_MPD, + /* streamKeys= */ ImmutableList.of(), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ null); + DownloadRequest expectedRequest2 = + new DownloadRequest( + /* id= */ "http://www.test.com/2/manifest.mpd", + Uri.parse("http://www.test.com/2/manifest.mpd"), + MimeTypes.APPLICATION_MPD, + ImmutableList.of( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0), + new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1)), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ new byte[] {0, 1, 2, 3}); + + ActionFileUpgradeUtil.upgradeAndDelete( + tempFile, + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + /* addNewDownloadsAsCompleted= */ false); + + assertThat(tempFile.exists()).isFalse(); + assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); + assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); + } + + @Test + public void upgradeAndDelete_hlsActionFile_createsDownloads() throws IOException { + byte[] actionFileBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "offline/action_file_for_download_index_upgrade_hls.exi"); + try (FileOutputStream output = new FileOutputStream(tempFile)) { + output.write(actionFileBytes); + } + DownloadRequest expectedRequest1 = + new DownloadRequest( + /* id= */ "http://www.test.com/1/manifest.m3u8", + Uri.parse("http://www.test.com/1/manifest.m3u8"), + MimeTypes.APPLICATION_M3U8, + /* streamKeys= */ ImmutableList.of(), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ null); + DownloadRequest expectedRequest2 = + new DownloadRequest( + /* id= */ "http://www.test.com/2/manifest.m3u8", + Uri.parse("http://www.test.com/2/manifest.m3u8"), + MimeTypes.APPLICATION_M3U8, + ImmutableList.of( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0), + new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1)), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ new byte[] {0, 1, 2, 3}); + + ActionFileUpgradeUtil.upgradeAndDelete( + tempFile, + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + /* addNewDownloadsAsCompleted= */ false); + + assertThat(tempFile.exists()).isFalse(); + assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); + assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); + } + + @Test + public void upgradeAndDelete_smoothStreamingActionFile_createsDownloads() throws IOException { + byte[] actionFileBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "offline/action_file_for_download_index_upgrade_ss.exi"); + try (FileOutputStream output = new FileOutputStream(tempFile)) { + output.write(actionFileBytes); + } + DownloadRequest expectedRequest1 = + new DownloadRequest( + /* id= */ "http://www.test.com/1/video.ism/manifest", + Uri.parse("http://www.test.com/1/video.ism/manifest"), + MimeTypes.APPLICATION_SS, + /* streamKeys= */ ImmutableList.of(), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ null); + DownloadRequest expectedRequest2 = + new DownloadRequest( + /* id= */ "http://www.test.com/2/video.ism/manifest", + Uri.parse("http://www.test.com/2/video.ism/manifest"), + MimeTypes.APPLICATION_SS, + ImmutableList.of( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0), + new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1)), + /* keySetId= */ null, + /* customCacheKey= */ null, + /* data= */ new byte[] {0, 1, 2, 3}); + + ActionFileUpgradeUtil.upgradeAndDelete( + tempFile, + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + /* addNewDownloadsAsCompleted= */ false); + + assertThat(tempFile.exists()).isFalse(); assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); } @@ -109,7 +229,7 @@ public class ActionFileUpgradeUtilTest { /* id= */ "id", Uri.parse("https://www.test.com/download"), /* mimeType= */ null, - asList( + ImmutableList.of( new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)), /* keySetId= */ new byte[] {1, 2, 3, 4}, @@ -133,7 +253,7 @@ public class ActionFileUpgradeUtilTest { /* id= */ "id", Uri.parse("https://www.test.com/download1"), /* mimeType= */ null, - asList(streamKey1), + ImmutableList.of(streamKey1), /* keySetId= */ new byte[] {1, 2, 3, 4}, /* customCacheKey= */ "key123", /* data= */ new byte[] {1, 2, 3, 4}); @@ -142,7 +262,7 @@ public class ActionFileUpgradeUtilTest { /* id= */ "id", Uri.parse("https://www.test.com/download2"), /* mimeType= */ MimeTypes.APPLICATION_MP4, - asList(streamKey2), + ImmutableList.of(streamKey2), /* keySetId= */ new byte[] {5, 4, 3, 2, 1}, /* customCacheKey= */ "key345", /* data= */ new byte[] {5, 4, 3, 2, 1}); @@ -174,7 +294,7 @@ public class ActionFileUpgradeUtilTest { /* id= */ "id1", Uri.parse("https://www.test.com/download1"), /* mimeType= */ null, - asList(streamKey1), + ImmutableList.of(streamKey1), /* keySetId= */ new byte[] {1, 2, 3, 4}, /* customCacheKey= */ "key123", /* data= */ new byte[] {1, 2, 3, 4}); @@ -183,7 +303,7 @@ public class ActionFileUpgradeUtilTest { /* id= */ "id2", Uri.parse("https://www.test.com/download2"), /* mimeType= */ null, - asList(streamKey2), + ImmutableList.of(streamKey2), /* keySetId= */ new byte[] {5, 4, 3, 2, 1}, /* customCacheKey= */ "key123", /* data= */ new byte[] {5, 4, 3, 2, 1}); @@ -207,8 +327,4 @@ public class ActionFileUpgradeUtilTest { assertThat(download.request).isEqualTo(request); assertThat(download.state).isEqualTo(state); } - - private static List asList(StreamKey... streamKeys) { - return Arrays.asList(streamKeys); - } } diff --git a/testdata/src/test/assets/offline/action_file_for_download_index_upgrade.exi b/testdata/src/test/assets/offline/action_file_for_download_index_upgrade.exi deleted file mode 100644 index 0bf49b133a1c91e9542671bad37539141a8f953d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 161 zcmZQz00SllmXg%s5+Iw2K`x`Dq@dVJU%$M(Tn{9wmzt!pOwT w0;Cy%m>I+eVpc{5w(QhOLnC9N%`yUNV_;=vVP*mu1i~NyqJaV+!;Fkg03X{PQ~&?~ diff --git a/testdata/src/test/assets/offline/action_file_for_download_index_upgrade_dash.exi b/testdata/src/test/assets/offline/action_file_for_download_index_upgrade_dash.exi new file mode 100644 index 0000000000000000000000000000000000000000..9c249a377eb4efe157bc08906206ff9dcb081842 GIT binary patch literal 146 zcmZQz00SllmXyTe3@}?Mqokz3N?*Ucyj-s&wYWqtIX_q5P(L>@FEb6q%`HfQXabQ0 Yv>9P&V_{%qVg_ntLYM((F#=hP0B~O%cmMzZ literal 0 HcmV?d00001 diff --git a/testdata/src/test/assets/offline/action_file_for_download_index_upgrade_hls.exi b/testdata/src/test/assets/offline/action_file_for_download_index_upgrade_hls.exi new file mode 100644 index 0000000000000000000000000000000000000000..a6315a80ef4d4bfd0177ba698ca53e1caf07676e GIT binary patch literal 146 zcmZQz00Sll=8T+TAd`_nIisYcz)D}gyu4hmB(=CiFF8L~-%vj{F)uR>#LYD>wSZ^> ak@&S4VQOPxU}RzjYGgu~0cSA+S&RTibQ|CR literal 0 HcmV?d00001 diff --git a/testdata/src/test/assets/offline/action_file_for_download_index_upgrade_progressive.exi b/testdata/src/test/assets/offline/action_file_for_download_index_upgrade_progressive.exi new file mode 100644 index 0000000000000000000000000000000000000000..477bd0815a5c80d19c275ee2440eb9de4d667185 GIT binary patch literal 140 zcmZQz00Sll?t-HH^rF<_;>@yCu#kL4NlAf~zJ7Umxn4 kfeAz-k@}2K^|3H8GBGnU@FkZPm*nR Date: Mon, 20 Jul 2020 18:05:14 +0100 Subject: [PATCH 0726/1052] Removes random ABR support in demo app - Removed random ABR option from popup menu - Remvoed intent parameter regarding random abr, default to AdaptiveTrackSelection. Removed logic for getting track selection mode from an intent - Remvoed string definition regarding random ABR PiperOrigin-RevId: 322167816 --- .../android/exoplayer2/demo/IntentUtil.java | 6 ------ .../android/exoplayer2/demo/PlayerActivity.java | 17 +---------------- .../exoplayer2/demo/SampleChooserActivity.java | 7 ------- .../src/main/res/menu/sample_chooser_menu.xml | 4 ---- demos/main/src/main/res/values/strings.xml | 2 -- 5 files changed, 1 insertion(+), 35 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java index 7a27b600be..65c077f55e 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -65,12 +65,6 @@ public class IntentUtil { public static final String SPHERICAL_STEREO_MODE_TOP_BOTTOM = "top_bottom"; public static final String SPHERICAL_STEREO_MODE_LEFT_RIGHT = "left_right"; - // Player configuration extras. - - public static final String ABR_ALGORITHM_EXTRA = "abr_algorithm"; - public static final String ABR_ALGORITHM_DEFAULT = "default"; - public static final String ABR_ALGORITHM_RANDOM = "random"; - // Media item configuration extras. public static final String URI_EXTRA = "uri"; diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 8ddecdad59..050e5c0d49 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -45,11 +45,8 @@ import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; -import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.trackselection.RandomTrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.DebugTextViewHelper; import com.google.android.exoplayer2.ui.StyledPlayerControlView; @@ -300,24 +297,12 @@ public class PlayerActivity extends AppCompatActivity return; } - TrackSelection.Factory trackSelectionFactory; - String abrAlgorithm = intent.getStringExtra(IntentUtil.ABR_ALGORITHM_EXTRA); - if (abrAlgorithm == null || IntentUtil.ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) { - trackSelectionFactory = new AdaptiveTrackSelection.Factory(); - } else if (IntentUtil.ABR_ALGORITHM_RANDOM.equals(abrAlgorithm)) { - trackSelectionFactory = new RandomTrackSelection.Factory(); - } else { - showToast(R.string.error_unrecognized_abr_algorithm); - finish(); - return; - } - boolean preferExtensionDecoders = intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false); RenderersFactory renderersFactory = DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); - trackSelector = new DefaultTrackSelector(/* context= */ this, trackSelectionFactory); + trackSelector = new DefaultTrackSelector(/* context= */ this); trackSelector.setParameters(trackSelectorParameters); lastSeenTrackGroupArray = null; diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index ae79fb84f5..703eb921a9 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -79,7 +79,6 @@ public class SampleChooserActivity extends AppCompatActivity private DownloadTracker downloadTracker; private SampleAdapter sampleAdapter; private MenuItem preferExtensionDecodersMenuItem; - private MenuItem randomAbrMenuItem; private ExpandableListView sampleListView; @Override @@ -135,7 +134,6 @@ public class SampleChooserActivity extends AppCompatActivity inflater.inflate(R.menu.sample_chooser_menu, menu); preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders); preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers); - randomAbrMenuItem = menu.findItem(R.id.random_abr); return true; } @@ -229,11 +227,6 @@ public class SampleChooserActivity extends AppCompatActivity intent.putExtra( IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, isNonNullAndChecked(preferExtensionDecodersMenuItem)); - String abrAlgorithm = - isNonNullAndChecked(randomAbrMenuItem) - ? IntentUtil.ABR_ALGORITHM_RANDOM - : IntentUtil.ABR_ALGORITHM_DEFAULT; - intent.putExtra(IntentUtil.ABR_ALGORITHM_EXTRA, abrAlgorithm); IntentUtil.addToIntent(playlistHolder.mediaItems, intent); startActivity(intent); return true; diff --git a/demos/main/src/main/res/menu/sample_chooser_menu.xml b/demos/main/src/main/res/menu/sample_chooser_menu.xml index 9934e9db95..259b2f0f38 100644 --- a/demos/main/src/main/res/menu/sample_chooser_menu.xml +++ b/demos/main/src/main/res/menu/sample_chooser_menu.xml @@ -19,8 +19,4 @@ android:title="@string/prefer_extension_decoders" android:checkable="true" app:showAsAction="never"/> - diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index d61f08852c..2cacaf5c37 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -67,6 +67,4 @@ Prefer extension decoders - Enable random ABR - From 08f62efb88688f92d136ecee21140c94311d7b3c Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 20 Jul 2020 18:27:04 +0100 Subject: [PATCH 0727/1052] Remove experimental time limit on renderer loop. PiperOrigin-RevId: 322172767 --- .../mediacodec/MediaCodecRenderer.java | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 8ca4261afc..fe2a3db44c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -362,7 +362,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Nullable private DrmSession sourceDrmSession; @Nullable private MediaCrypto mediaCrypto; private boolean mediaCryptoRequiresSecureDecoder; - private long renderTimeLimitMs; private float operatingRate; @Nullable private MediaCodec codec; @Nullable private MediaCodecAdapter codecAdapter; @@ -440,7 +439,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { decodeOnlyPresentationTimestamps = new ArrayList<>(); outputBufferInfo = new MediaCodec.BufferInfo(); operatingRate = 1f; - renderTimeLimitMs = C.TIME_UNSET; mediaCodecOperationMode = OPERATION_MODE_SYNCHRONOUS; pendingOutputStreamStartPositionsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; @@ -451,20 +449,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { resetCodecStateForRelease(); } - /** - * Set a limit on the time a single {@link #render(long, long)} call can spend draining and - * filling the decoder. - * - *

        This method is experimental, and will be renamed or removed in a future release. It should - * only be called before the renderer is used. - * - * @param renderTimeLimitMs The render time limit in milliseconds, or {@link C#TIME_UNSET} for no - * limit. - */ - public void experimental_setRenderTimeLimitMs(long renderTimeLimitMs) { - this.renderTimeLimitMs = renderTimeLimitMs; - } - /** * Set the mode of operation of the underlying {@link MediaCodec}. * @@ -831,11 +815,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { while (bypassRender(positionUs, elapsedRealtimeUs)) {} TraceUtil.endSection(); } else if (codec != null) { - long renderStartTimeMs = SystemClock.elapsedRealtime(); TraceUtil.beginSection("drainAndFeed"); - while (drainOutputBuffer(positionUs, elapsedRealtimeUs) - && shouldContinueRendering(renderStartTimeMs)) {} - while (feedInputBuffer() && shouldContinueRendering(renderStartTimeMs)) {} + while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) {} + while (feedInputBuffer()) {} TraceUtil.endSection(); } else { decoderCounters.skippedInputBufferCount += skipSource(positionUs); @@ -1164,11 +1146,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { onCodecInitialized(codecName, codecInitializedTimestamp, elapsed); } - private boolean shouldContinueRendering(long renderStartTimeMs) { - return renderTimeLimitMs == C.TIME_UNSET - || SystemClock.elapsedRealtime() - renderStartTimeMs < renderTimeLimitMs; - } - private void getCodecBuffers(MediaCodec codec) { if (Util.SDK_INT < 21) { inputBuffers = codec.getInputBuffers(); From 73df8e4a260934d9be55e64b0298f21613c7661f Mon Sep 17 00:00:00 2001 From: claincly Date: Mon, 20 Jul 2020 20:57:00 +0100 Subject: [PATCH 0728/1052] Removes spherical stereo play back mode - Removed corresponding playback examples in the resouce JSON files. - Removed the spherical style declaration. - Removed spherical stereo mode related Intent settings, and - Removed code to play back media in spherical stereo mode BUG=160460714 (grafted from 595fe17a480d5bc64d0198130150d8e8a5daa679) PiperOrigin-RevId: 322206314 --- RELEASENOTES.md | 3 +++ demos/main/src/main/assets/media.exolist.json | 20 ------------------- .../android/exoplayer2/demo/IntentUtil.java | 15 ++------------ .../exoplayer2/demo/PlayerActivity.java | 20 ------------------- .../demo/SampleChooserActivity.java | 8 +------- demos/main/src/main/res/values/styles.xml | 4 ---- 6 files changed, 6 insertions(+), 64 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cb7dbae348..a8f820aff1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -254,6 +254,9 @@ * Retain previous position in list of samples. * Replace the `extensions` variant with `decoderExtensions` and make the demo app use the Cronet and IMA extensions by default. + * Removed support for media tunneling + * Removed support for random ABR (random track selection) + * Removed support for playing back in spherical stereo mode * Add Guava dependency. ### 2.11.7 (2020-06-29) ### diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 9bdd697394..b6fc409299 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -495,26 +495,6 @@ } ] }, - { - "name": "360", - "samples": [ - { - "name": "Congo (360 top-bottom stereo)", - "uri": "https://storage.googleapis.com/exoplayer-test-media-1/360/congo.mp4", - "spherical_stereo_mode": "top_bottom" - }, - { - "name": "Sphericalv2 (180 top-bottom stereo)", - "uri": "https://storage.googleapis.com/exoplayer-test-media-1/360/sphericalv2.mp4", - "spherical_stereo_mode": "top_bottom" - }, - { - "name": "Iceland (360 top-bottom stereo ts)", - "uri": "https://storage.googleapis.com/exoplayer-test-media-1/360/iceland0.ts", - "spherical_stereo_mode": "top_bottom" - } - ] - }, { "name": "Subtitles", "samples": [ diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java index 65c077f55e..4656ca79ac 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -42,13 +42,10 @@ public class IntentUtil { /** Whether the stream is a live stream. */ public final boolean isLive; - /** The spherical stereo mode or null. */ - @Nullable public final String sphericalStereoMode; /** Creates an instance. */ - public Tag(boolean isLive, @Nullable String sphericalStereoMode) { + public Tag(boolean isLive) { this.isLive = isLive; - this.sphericalStereoMode = sphericalStereoMode; } } @@ -60,11 +57,6 @@ public class IntentUtil { // Activity extras. - public static final String SPHERICAL_STEREO_MODE_EXTRA = "spherical_stereo_mode"; - public static final String SPHERICAL_STEREO_MODE_MONO = "mono"; - public static final String SPHERICAL_STEREO_MODE_TOP_BOTTOM = "top_bottom"; - public static final String SPHERICAL_STEREO_MODE_LEFT_RIGHT = "left_right"; - // Media item configuration extras. public static final String URI_EXTRA = "uri"; @@ -237,19 +229,16 @@ public class IntentUtil { private static void addPlaybackPropertiesToIntent( MediaItem.PlaybackProperties playbackProperties, Intent intent, String extrasKeySuffix) { boolean isLive = false; - String sphericalStereoMode = null; if (playbackProperties.tag instanceof Tag) { Tag tag = (Tag) playbackProperties.tag; isLive = tag.isLive; - sphericalStereoMode = tag.sphericalStereoMode; } intent .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType) .putExtra( AD_TAG_URI_EXTRA + extrasKeySuffix, playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null) - .putExtra(IS_LIVE_EXTRA + extrasKeySuffix, isLive) - .putExtra(SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode); + .putExtra(IS_LIVE_EXTRA + extrasKeySuffix, isLive); if (playbackProperties.drmConfiguration != null) { addDrmConfigurationToIntent(playbackProperties.drmConfiguration, intent, extrasKeySuffix); } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 050e5c0d49..ea518e7bb3 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -51,7 +51,6 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.DebugTextViewHelper; import com.google.android.exoplayer2.ui.StyledPlayerControlView; import com.google.android.exoplayer2.ui.StyledPlayerView; -import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ErrorMessageProvider; @@ -108,10 +107,6 @@ public class PlayerActivity extends AppCompatActivity @Override public void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); - String sphericalStereoMode = intent.getStringExtra(IntentUtil.SPHERICAL_STEREO_MODE_EXTRA); - if (sphericalStereoMode != null) { - setTheme(R.style.PlayerTheme_Spherical); - } super.onCreate(savedInstanceState); dataSourceFactory = buildDataSourceFactory(); if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { @@ -128,21 +123,6 @@ public class PlayerActivity extends AppCompatActivity playerView.setControllerVisibilityListener(this); playerView.setErrorMessageProvider(new PlayerErrorMessageProvider()); playerView.requestFocus(); - if (sphericalStereoMode != null) { - int stereoMode; - if (IntentUtil.SPHERICAL_STEREO_MODE_MONO.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_MONO; - } else if (IntentUtil.SPHERICAL_STEREO_MODE_TOP_BOTTOM.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_TOP_BOTTOM; - } else if (IntentUtil.SPHERICAL_STEREO_MODE_LEFT_RIGHT.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_LEFT_RIGHT; - } else { - showToast(R.string.error_unrecognized_stereo_mode); - finish(); - return; - } - ((SphericalGLSurfaceView) playerView.getVideoSurfaceView()).setDefaultStereoMode(stereoMode); - } if (savedInstanceState != null) { trackSelectorParameters = savedInstanceState.getParcelable(KEY_TRACK_SELECTOR_PARAMETERS); diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 703eb921a9..dd014547ec 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -350,7 +350,6 @@ public class SampleChooserActivity extends AppCompatActivity String extension = null; String title = null; boolean isLive = false; - String sphericalStereoMode = null; ArrayList children = null; Uri subtitleUri = null; String subtitleMimeType = null; @@ -415,11 +414,6 @@ public class SampleChooserActivity extends AppCompatActivity case "ad_tag_uri": mediaItem.setAdTagUri(reader.nextString()); break; - case "spherical_stereo_mode": - Assertions.checkState( - !insidePlaylist, "Invalid attribute on nested item: spherical_stereo_mode"); - sphericalStereoMode = reader.nextString(); - break; case "subtitle_uri": subtitleUri = Uri.parse(reader.nextString()); break; @@ -446,7 +440,7 @@ public class SampleChooserActivity extends AppCompatActivity .setUri(uri) .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build()) .setMimeType(IntentUtil.inferAdaptiveStreamMimeType(uri, extension)) - .setTag(new IntentUtil.Tag(isLive, sphericalStereoMode)); + .setTag(new IntentUtil.Tag(isLive)); if (subtitleUri != null) { MediaItem.Subtitle subtitle = new MediaItem.Subtitle( diff --git a/demos/main/src/main/res/values/styles.xml b/demos/main/src/main/res/values/styles.xml index a2ebde37bd..3a8740d80a 100644 --- a/demos/main/src/main/res/values/styles.xml +++ b/demos/main/src/main/res/values/styles.xml @@ -23,8 +23,4 @@ @android:color/black - - From 8dd564c9a8596e1443c074e0a7b3920593894b11 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 21 Jul 2020 08:55:35 +0100 Subject: [PATCH 0729/1052] Remove Mp4Extractor from nullness exclusion list PiperOrigin-RevId: 322310474 --- .../exoplayer2/extractor/mp4/Mp4Extractor.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index 13668404cf..938d66e12a 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.extractor.mp4; import static com.google.android.exoplayer2.extractor.mp4.AtomParsers.parseTraks; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -46,6 +48,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Extracts data from the MP4 container format. @@ -118,8 +121,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Extractor outputs. private @MonotonicNonNull ExtractorOutput extractorOutput; - private Mp4Track[] tracks; - private long[][] accumulatedSampleSizes; + private Mp4Track @MonotonicNonNull [] tracks; + private long @MonotonicNonNull [][] accumulatedSampleSizes; private int firstVideoTrackIndex; private long durationUs; private boolean isQuickTime; @@ -213,7 +216,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { @Override public SeekPoints getSeekPoints(long timeUs) { - if (tracks.length == 0) { + if (checkNotNull(tracks).length == 0) { return new SeekPoints(SeekPoint.START); } @@ -346,6 +349,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { long atomPayloadSize = atomSize - atomHeaderBytesRead; long atomEndPosition = input.getPosition() + atomPayloadSize; boolean seekRequired = false; + @Nullable ParsableByteArray atomData = this.atomData; if (atomData != null) { input.readFully(atomData.data, atomHeaderBytesRead, (int) atomPayloadSize); if (atomType == Atom.TYPE_ftyp) { @@ -418,6 +422,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { isQuickTime, /* modifyTrackFunction= */ track -> track); + ExtractorOutput extractorOutput = checkNotNull(this.extractorOutput); int trackCount = trackSampleTables.size(); for (int i = 0; i < trackCount; i++) { TrackSampleTable trackSampleTable = trackSampleTables.get(i); @@ -483,7 +488,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { return RESULT_END_OF_INPUT; } } - Mp4Track track = tracks[sampleTrackIndex]; + Mp4Track track = castNonNull(tracks)[sampleTrackIndex]; TrackOutput trackOutput = track.trackOutput; int sampleIndex = track.sampleIndex; long position = track.sampleTable.offsets[sampleIndex]; @@ -583,14 +588,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { long minAccumulatedBytes = Long.MAX_VALUE; boolean minAccumulatedBytesRequiresReload = true; int minAccumulatedBytesTrackIndex = C.INDEX_UNSET; - for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + for (int trackIndex = 0; trackIndex < castNonNull(tracks).length; trackIndex++) { Mp4Track track = tracks[trackIndex]; int sampleIndex = track.sampleIndex; if (sampleIndex == track.sampleTable.sampleCount) { continue; } long sampleOffset = track.sampleTable.offsets[sampleIndex]; - long sampleAccumulatedBytes = accumulatedSampleSizes[trackIndex][sampleIndex]; + long sampleAccumulatedBytes = castNonNull(accumulatedSampleSizes)[trackIndex][sampleIndex]; long skipAmount = sampleOffset - inputPosition; boolean requiresReload = skipAmount < 0 || skipAmount >= RELOAD_MINIMUM_SEEK_DISTANCE; if ((!requiresReload && preferredRequiresReload) @@ -616,6 +621,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { /** * Updates every track's sample index to point its latest sync sample before/at {@code timeUs}. */ + @RequiresNonNull("tracks") private void updateSampleIndices(long timeUs) { for (Mp4Track track : tracks) { TrackSampleTable sampleTable = track.sampleTable; From 34ed79e6d9914c0c5dffee3c265c18f67cd0cb5a Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 21 Jul 2020 09:02:02 +0100 Subject: [PATCH 0730/1052] Remove nullness warnings in extractors #exofixit PiperOrigin-RevId: 322311309 --- .../com/google/android/exoplayer2/extractor/ts/H264Reader.java | 2 +- .../com/google/android/exoplayer2/extractor/ts/H265Reader.java | 2 +- .../google/android/exoplayer2/extractor/ts/MpegAudioReader.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java index 55f5fb34c6..f212117252 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.extractor.ts; import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR; import android.util.SparseArray; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -36,7 +37,6 @@ import java.util.Arrays; import java.util.List; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index c356b1c987..c310d8c31d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.extractor.ts; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -30,7 +31,6 @@ import com.google.android.exoplayer2.util.Util; import java.util.Collections; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java index 44870c3025..d143ecb380 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.extractor.ts; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.MpegAudioUtil; @@ -24,7 +25,6 @@ import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerat import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** From 7b4d7d9aa4eb4f581b6b26132e61f7950b3c7096 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 21 Jul 2020 09:04:07 +0100 Subject: [PATCH 0731/1052] Remove invalid documentation that causes javadoc to crash PiperOrigin-RevId: 322311636 --- .../google/android/exoplayer2/mediacodec/MediaFormatUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java index 118445835b..0ed58db266 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java @@ -83,7 +83,7 @@ public final class MediaFormatUtil { * * @param format The {@link MediaFormat} being configured. * @param key The key to set. - * @param value The {@link byte[]} that will be wrapped to obtain the value. + * @param value The byte array that will be wrapped to obtain the value. */ public static void maybeSetByteBuffer(MediaFormat format, String key, @Nullable byte[] value) { if (value != null) { From 63ca2b00fb9608bf86aee4a50c4b365b4b39c235 Mon Sep 17 00:00:00 2001 From: insun Date: Tue, 21 Jul 2020 09:24:15 +0100 Subject: [PATCH 0732/1052] Resolve styled controls crash on pre-API 21 When building a demo app without recent gradle (ex. blaze) it crashes. PiperOrigin-RevId: 322313886 --- .../exo_edit_mode_logo.xml | 0 .../exo_ic_audiotrack.xml | 0 .../exo_ic_check.xml | 0 .../exo_ic_chevron_left.xml | 0 .../exo_ic_chevron_right.xml | 0 .../exo_ic_default_album_image.xml | 0 .../exo_ic_forward.xml | 0 .../exo_ic_forward_30.xml | 0 .../exo_ic_fullscreen_enter.xml | 0 .../exo_ic_fullscreen_exit.xml | 0 .../exo_ic_launch.xml | 0 .../exo_ic_pause_circle_filled.xml | 0 .../exo_ic_play_circle_filled.xml | 0 .../exo_ic_rewind.xml | 0 .../exo_ic_rewind_10.xml | 0 .../exo_ic_settings.xml | 0 .../exo_ic_skip_next.xml | 0 .../exo_ic_skip_previous.xml | 0 .../exo_ic_speed.xml | 0 .../exo_ic_subtitle_off.xml | 0 .../exo_ic_subtitle_on.xml | 0 .../exo_ic_replay_circle_filled.xml | 0 .../res/drawable-hdpi/exo_edit_mode_logo.png | Bin 0 -> 458 bytes .../main/res/drawable-hdpi/exo_ic_audiotrack.png | Bin 0 -> 390 bytes .../src/main/res/drawable-hdpi/exo_ic_check.png | Bin 0 -> 175 bytes .../res/drawable-hdpi/exo_ic_chevron_left.png | Bin 0 -> 140 bytes .../res/drawable-hdpi/exo_ic_chevron_right.png | Bin 0 -> 141 bytes .../drawable-hdpi/exo_ic_default_album_image.png | Bin 0 -> 1362 bytes .../main/res/drawable-hdpi/exo_ic_forward.png | Bin 0 -> 229 bytes .../drawable-hdpi/exo_ic_fullscreen_enter.png | Bin 0 -> 139 bytes .../res/drawable-hdpi/exo_ic_fullscreen_exit.png | Bin 0 -> 125 bytes .../src/main/res/drawable-hdpi/exo_ic_launch.png | Bin 0 -> 248 bytes .../drawable-hdpi/exo_ic_pause_circle_filled.png | Bin 0 -> 362 bytes .../drawable-hdpi/exo_ic_play_circle_filled.png | Bin 0 -> 391 bytes .../exo_ic_replay_circle_filled.png | Bin 0 -> 633 bytes .../src/main/res/drawable-hdpi/exo_ic_rewind.png | Bin 0 -> 230 bytes .../main/res/drawable-hdpi/exo_ic_settings.png | Bin 0 -> 309 bytes .../main/res/drawable-hdpi/exo_ic_skip_next.png | Bin 0 -> 225 bytes .../res/drawable-hdpi/exo_ic_skip_previous.png | Bin 0 -> 230 bytes .../src/main/res/drawable-hdpi/exo_ic_speed.png | Bin 0 -> 386 bytes .../res/drawable-hdpi/exo_ic_subtitle_off.png | Bin 0 -> 224 bytes .../res/drawable-hdpi/exo_ic_subtitle_on.png | Bin 0 -> 221 bytes .../res/drawable-ldpi/exo_edit_mode_logo.png | Bin 0 -> 265 bytes .../main/res/drawable-ldpi/exo_ic_audiotrack.png | Bin 0 -> 229 bytes .../src/main/res/drawable-ldpi/exo_ic_check.png | Bin 0 -> 138 bytes .../res/drawable-ldpi/exo_ic_chevron_left.png | Bin 0 -> 112 bytes .../res/drawable-ldpi/exo_ic_chevron_right.png | Bin 0 -> 113 bytes .../drawable-ldpi/exo_ic_default_album_image.png | Bin 0 -> 675 bytes .../main/res/drawable-ldpi/exo_ic_forward.png | Bin 0 -> 145 bytes .../drawable-ldpi/exo_ic_fullscreen_enter.png | Bin 0 -> 135 bytes .../res/drawable-ldpi/exo_ic_fullscreen_exit.png | Bin 0 -> 130 bytes .../src/main/res/drawable-ldpi/exo_ic_launch.png | Bin 0 -> 178 bytes .../drawable-ldpi/exo_ic_pause_circle_filled.png | Bin 0 -> 220 bytes .../drawable-ldpi/exo_ic_play_circle_filled.png | Bin 0 -> 247 bytes .../exo_ic_replay_circle_filled.png | Bin 0 -> 358 bytes .../src/main/res/drawable-ldpi/exo_ic_rewind.png | Bin 0 -> 143 bytes .../main/res/drawable-ldpi/exo_ic_settings.png | Bin 0 -> 193 bytes .../main/res/drawable-ldpi/exo_ic_skip_next.png | Bin 0 -> 164 bytes .../res/drawable-ldpi/exo_ic_skip_previous.png | Bin 0 -> 173 bytes .../src/main/res/drawable-ldpi/exo_ic_speed.png | Bin 0 -> 233 bytes .../res/drawable-ldpi/exo_ic_subtitle_off.png | Bin 0 -> 174 bytes .../res/drawable-ldpi/exo_ic_subtitle_on.png | Bin 0 -> 161 bytes .../res/drawable-mdpi/exo_edit_mode_logo.png | Bin 0 -> 368 bytes .../main/res/drawable-mdpi/exo_ic_audiotrack.png | Bin 0 -> 206 bytes .../src/main/res/drawable-mdpi/exo_ic_check.png | Bin 0 -> 141 bytes .../res/drawable-mdpi/exo_ic_chevron_left.png | Bin 0 -> 113 bytes .../res/drawable-mdpi/exo_ic_chevron_right.png | Bin 0 -> 116 bytes .../drawable-mdpi/exo_ic_default_album_image.png | Bin 0 -> 855 bytes .../main/res/drawable-mdpi/exo_ic_forward.png | Bin 0 -> 152 bytes .../drawable-mdpi/exo_ic_fullscreen_enter.png | Bin 0 -> 98 bytes .../res/drawable-mdpi/exo_ic_fullscreen_exit.png | Bin 0 -> 96 bytes .../src/main/res/drawable-mdpi/exo_ic_launch.png | Bin 0 -> 171 bytes .../drawable-mdpi/exo_ic_pause_circle_filled.png | Bin 0 -> 282 bytes .../drawable-mdpi/exo_ic_play_circle_filled.png | Bin 0 -> 294 bytes .../exo_ic_replay_circle_filled.png | Bin 0 -> 452 bytes .../src/main/res/drawable-mdpi/exo_ic_rewind.png | Bin 0 -> 155 bytes .../main/res/drawable-mdpi/exo_ic_settings.png | Bin 0 -> 218 bytes .../main/res/drawable-mdpi/exo_ic_skip_next.png | Bin 0 -> 186 bytes .../res/drawable-mdpi/exo_ic_skip_previous.png | Bin 0 -> 188 bytes .../src/main/res/drawable-mdpi/exo_ic_speed.png | Bin 0 -> 269 bytes .../res/drawable-mdpi/exo_ic_subtitle_off.png | Bin 0 -> 157 bytes .../res/drawable-mdpi/exo_ic_subtitle_on.png | Bin 0 -> 145 bytes .../res/drawable-xhdpi/exo_edit_mode_logo.png | Bin 0 -> 658 bytes .../res/drawable-xhdpi/exo_ic_audiotrack.png | Bin 0 -> 346 bytes .../src/main/res/drawable-xhdpi/exo_ic_check.png | Bin 0 -> 192 bytes .../res/drawable-xhdpi/exo_ic_chevron_left.png | Bin 0 -> 153 bytes .../res/drawable-xhdpi/exo_ic_chevron_right.png | Bin 0 -> 157 bytes .../exo_ic_default_album_image.png | Bin 0 -> 1892 bytes .../main/res/drawable-xhdpi/exo_ic_forward.png | Bin 0 -> 213 bytes .../drawable-xhdpi/exo_ic_fullscreen_enter.png | Bin 0 -> 101 bytes .../drawable-xhdpi/exo_ic_fullscreen_exit.png | Bin 0 -> 101 bytes .../main/res/drawable-xhdpi/exo_ic_launch.png | Bin 0 -> 258 bytes .../exo_ic_pause_circle_filled.png | Bin 0 -> 478 bytes .../drawable-xhdpi/exo_ic_play_circle_filled.png | Bin 0 -> 509 bytes .../exo_ic_replay_circle_filled.png | Bin 0 -> 851 bytes .../main/res/drawable-xhdpi/exo_ic_rewind.png | Bin 0 -> 217 bytes .../main/res/drawable-xhdpi/exo_ic_settings.png | Bin 0 -> 386 bytes .../main/res/drawable-xhdpi/exo_ic_skip_next.png | Bin 0 -> 265 bytes .../res/drawable-xhdpi/exo_ic_skip_previous.png | Bin 0 -> 273 bytes .../src/main/res/drawable-xhdpi/exo_ic_speed.png | Bin 0 -> 501 bytes .../res/drawable-xhdpi/exo_ic_subtitle_off.png | Bin 0 -> 214 bytes .../res/drawable-xhdpi/exo_ic_subtitle_on.png | Bin 0 -> 202 bytes .../res/drawable-xxhdpi/exo_edit_mode_logo.png | Bin 0 -> 998 bytes .../res/drawable-xxhdpi/exo_ic_audiotrack.png | Bin 0 -> 513 bytes .../main/res/drawable-xxhdpi/exo_ic_check.png | Bin 0 -> 236 bytes .../res/drawable-xxhdpi/exo_ic_chevron_left.png | Bin 0 -> 198 bytes .../res/drawable-xxhdpi/exo_ic_chevron_right.png | Bin 0 -> 194 bytes .../exo_ic_default_album_image.png | Bin 0 -> 3131 bytes .../main/res/drawable-xxhdpi/exo_ic_forward.png | Bin 0 -> 327 bytes .../drawable-xxhdpi/exo_ic_fullscreen_enter.png | Bin 0 -> 107 bytes .../drawable-xxhdpi/exo_ic_fullscreen_exit.png | Bin 0 -> 105 bytes .../main/res/drawable-xxhdpi/exo_ic_launch.png | Bin 0 -> 306 bytes .../exo_ic_pause_circle_filled.png | Bin 0 -> 648 bytes .../exo_ic_play_circle_filled.png | Bin 0 -> 712 bytes .../exo_ic_replay_circle_filled.png | Bin 0 -> 1252 bytes .../main/res/drawable-xxhdpi/exo_ic_rewind.png | Bin 0 -> 334 bytes .../main/res/drawable-xxhdpi/exo_ic_settings.png | Bin 0 -> 574 bytes .../res/drawable-xxhdpi/exo_ic_skip_next.png | Bin 0 -> 336 bytes .../res/drawable-xxhdpi/exo_ic_skip_previous.png | Bin 0 -> 345 bytes .../main/res/drawable-xxhdpi/exo_ic_speed.png | Bin 0 -> 727 bytes .../res/drawable-xxhdpi/exo_ic_subtitle_off.png | Bin 0 -> 281 bytes .../res/drawable-xxhdpi/exo_ic_subtitle_on.png | Bin 0 -> 265 bytes .../res/drawable-xxxhdpi/exo_edit_mode_logo.png | Bin 0 -> 1397 bytes .../res/drawable-xxxhdpi/exo_ic_audiotrack.png | Bin 0 -> 650 bytes .../main/res/drawable-xxxhdpi/exo_ic_check.png | Bin 0 -> 277 bytes .../res/drawable-xxxhdpi/exo_ic_chevron_left.png | Bin 0 -> 248 bytes .../drawable-xxxhdpi/exo_ic_chevron_right.png | Bin 0 -> 249 bytes .../exo_ic_default_album_image.png | Bin 0 -> 4781 bytes .../main/res/drawable-xxxhdpi/exo_ic_forward.png | Bin 0 -> 387 bytes .../drawable-xxxhdpi/exo_ic_fullscreen_enter.png | Bin 0 -> 109 bytes .../drawable-xxxhdpi/exo_ic_fullscreen_exit.png | Bin 0 -> 106 bytes .../main/res/drawable-xxxhdpi/exo_ic_launch.png | Bin 0 -> 396 bytes .../exo_ic_pause_circle_filled.png | Bin 0 -> 1081 bytes .../exo_ic_play_circle_filled.png | Bin 0 -> 1152 bytes .../exo_ic_replay_circle_filled.png | Bin 0 -> 1812 bytes .../main/res/drawable-xxxhdpi/exo_ic_rewind.png | Bin 0 -> 394 bytes .../res/drawable-xxxhdpi/exo_ic_settings.png | Bin 0 -> 752 bytes .../res/drawable-xxxhdpi/exo_ic_skip_next.png | Bin 0 -> 428 bytes .../drawable-xxxhdpi/exo_ic_skip_previous.png | Bin 0 -> 433 bytes .../main/res/drawable-xxxhdpi/exo_ic_speed.png | Bin 0 -> 955 bytes .../res/drawable-xxxhdpi/exo_ic_subtitle_off.png | Bin 0 -> 316 bytes .../res/drawable-xxxhdpi/exo_ic_subtitle_on.png | Bin 0 -> 305 bytes 142 files changed, 0 insertions(+), 0 deletions(-) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_edit_mode_logo.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_audiotrack.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_check.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_chevron_left.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_chevron_right.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_default_album_image.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_forward.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_forward_30.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_fullscreen_enter.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_fullscreen_exit.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_launch.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_pause_circle_filled.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_play_circle_filled.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_rewind.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_rewind_10.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_settings.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_skip_next.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_skip_previous.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_speed.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_subtitle_off.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v21}/exo_ic_subtitle_on.xml (100%) rename library/ui/src/main/res/{drawable => drawable-anydpi-v24}/exo_ic_replay_circle_filled.xml (100%) create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_edit_mode_logo.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_audiotrack.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_check.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_left.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_right.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_default_album_image.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_forward.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_enter.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_exit.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_launch.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_pause_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_play_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_replay_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_rewind.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_settings.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_skip_next.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_skip_previous.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_speed.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_off.png create mode 100644 library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_on.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_edit_mode_logo.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_audiotrack.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_check.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_left.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_right.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_default_album_image.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_forward.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_enter.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_exit.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_launch.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_pause_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_play_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_replay_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_rewind.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_settings.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_skip_next.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_skip_previous.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_speed.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_off.png create mode 100644 library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_on.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_edit_mode_logo.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_audiotrack.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_check.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_left.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_right.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_default_album_image.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_forward.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_enter.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_exit.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_launch.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_pause_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_play_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_replay_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_rewind.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_settings.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_skip_next.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_skip_previous.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_speed.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_off.png create mode 100644 library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_on.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_edit_mode_logo.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_audiotrack.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_check.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_left.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_right.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_default_album_image.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_forward.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_enter.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_exit.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_launch.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_pause_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_play_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_replay_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_rewind.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_settings.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_next.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_previous.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_speed.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_off.png create mode 100644 library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_on.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_edit_mode_logo.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_audiotrack.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_check.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_left.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_right.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_default_album_image.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_forward.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_enter.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_exit.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_launch.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_pause_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_play_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_replay_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_rewind.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_settings.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_next.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_previous.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_speed.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_off.png create mode 100644 library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_on.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_edit_mode_logo.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_audiotrack.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_check.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_left.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_right.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_default_album_image.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_forward.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_enter.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_exit.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_launch.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_pause_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_play_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_replay_circle_filled.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_rewind.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_settings.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_next.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_previous.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_speed.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_off.png create mode 100644 library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_on.png diff --git a/library/ui/src/main/res/drawable/exo_edit_mode_logo.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_edit_mode_logo.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_edit_mode_logo.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_edit_mode_logo.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_audiotrack.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_audiotrack.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_audiotrack.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_check.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_check.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_check.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_check.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_left.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_chevron_left.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_left.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_right.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_chevron_right.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_right.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_default_album_image.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_default_album_image.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_default_album_image.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_forward.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_forward.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_forward_30.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward_30.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_forward_30.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward_30.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_enter.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_enter.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_exit.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_exit.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_launch.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_launch.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_launch.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_launch.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_pause_circle_filled.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_pause_circle_filled.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_play_circle_filled.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_play_circle_filled.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_rewind.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_rewind.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind_10.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_rewind_10.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind_10.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_settings.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_settings.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_settings.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_settings.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_skip_next.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_next.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_skip_next.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_next.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_previous.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_skip_previous.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_previous.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_speed.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_speed.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_speed.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_speed.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_off.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_off.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_on.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_on.xml diff --git a/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml b/library/ui/src/main/res/drawable-anydpi-v24/exo_ic_replay_circle_filled.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml rename to library/ui/src/main/res/drawable-anydpi-v24/exo_ic_replay_circle_filled.xml diff --git a/library/ui/src/main/res/drawable-hdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-hdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..49119345ebc7ce57f6d88d384533bd4cdb97c8b8 GIT binary patch literal 458 zcmV;*0X6=KP)FDltw13u)H)H+Q%$wP+sBaV%6$N0y11lcbV&>UD2NI#xhOI!%w5@wb0CfOZ7APOA za3EvMun0Yh(4_#6*4pVHX489u_ivMh0eRr8QmKrpU8~i)J;;y{potbuxF}n>~k!hL{!pm-0+cc|Zc37Yme4nb76ul(`K6>&>RjTHDtWpuoP!`T@5mz@aeR z7FN_j5ntrTxsyVF`%H7x+xH6y+86_%;u@oh84uy;(AsK@4h1T7bkuJ@lYnkSaGjRR zQ{iwG35M&xe17DE#3!s<)I#-e>n#R}ii(PQ2Y4iWdCgZPF8}}l07*qoM6N<$f=eUO Ac>n+a literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000000000000000000000000000000000..f034030e946ac26a14f4bfb51505e5c0a598984f GIT binary patch literal 390 zcmV;10eSw3P)1-gWE2?1s>fFb}15THNKv)7^D!M4tK zHhVsJzt{2j{RijnAE3jC`8PA76RR0;;U_K(;zV0+v5gZsLJquY4urTkQOFxL-_?`? z?4oYocB4eh(R?AQa|BF|rZktg~e*+t>4*+mhgh)%p~PQMfFc%;@m zrePQLm3la?IC^UOcG24BF&T)AvESEr5e)s-;#t%3c?c<@GcOeK69sMOB$4NXDG!>d zlCX&!w{UTyxaTNB^hB2WK!zg0J)bKer^-ecL?ob#K|jDI)#HQ28FZAq0w2>L)Bo=% kGYDan#Q$^xUZXVt0BRmc%G;iR8vp literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_check.png new file mode 100644 index 0000000000000000000000000000000000000000..8b0090016b4f18990ed6afe4ac125a05915d02e6 GIT binary patch literal 175 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbB$~;{hLn;{GUTb7!4is>`c(lD_ zsu)v{@1Lrsx7wyGeqItCCnuC^Tby|ytGV<`e&Rzr&SIu-Dm({Dh0@rCeGC>G?0DE| z%KDjYPK3R!upEQvs<-JIv_pC`UA883U0Es{ydduH45_RAD+ABYVA|TRSzGvW>6AFVdQ&MBb@0Gd)gKL7v# literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000000000000000000000000000000000..136a2f11f6cbec06036b77f70c042fb6f060061e GIT binary patch literal 140 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBf<0XvLn;{GUSniqP~dTU*kM)H zzG~M!Edh@uKR(*A9bmqFC!K-8*t@tSY1$GI^yfy$l0Ym*pjOWFE2MP+BEa nx>>?f_W9#ezdwF;WM(M2{x|Vi(!^eGpz#czu6{1-oD!M<5q~dw literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000000000000000000000000000000000..5524979c80cfd164fa282d5130861e2b0129e546 GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBLOfj@Ln;{GUSniqP~dU6*kM)H zzG~OKCaWI13og oopJlKKm=O9b^rIr>mdKI;Vst0KB3wf&c&j literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000000000000000000000000000000000..a6fe95766638c941d40b46cee9901ac334472381 GIT binary patch literal 1362 zcmeAS@N?(olHy`uVBq!ia0y~yUIyyQbAt5|GJTx>kI5;>YBqS^>EH*YaA|k>|U!a?Tf#t5Ji(^Oy;42&!e42%MV zm?!FQ?fDcxg{7d1n{Br69>@AcvKLw&d2W84CAoq=U*1D6IyWK$z-|A=j)@^ zuiZPF;qTQOE9TaletD3`^u@n;Z`r@g7xb9EobKKHY5$(W84R@>HU1wjd(CyB`Yi9~ z|Fv2UH$VQr`j=^n?Em<)!V8w`ofX_-C%T}$@3i1nmKSraZ(ABMR;4{H;|f?|Ifp4^ zmo4jxEk)4{L5DRRmWBp31Z`&3D0ScpSow1g!_qu&MlI8H&Va0?A`7nAGEIq61xofY zES1V-^jbTEY0575hM-`e9(Rs_>}?E7*?>%KATy#Ns28XqgfUB2(P8aPrYTuVfRd~$ zwze~DZDZBAn!_ECrNF9Ts^PHKmSqLdz^!SV0jp**O_35evv~%ycEn6Ubu~IFPV{f$7Fv6^GtVrjj$F4$j7uPMiS-W3I{&f$Ztpvuu?7y3$RkJiI}oU>(QzeDX|v<4DvQ_F21~I z%GQO}E}E+sZ|6GpcGjCaVh4Au%`n(zgZS zfv2ipm|bLK^F4k=D&kap)1R~$ZL34a+a6`TO;lNNZ9#g$+LLN6-FGL?yZrfwPfefO z>mv)y*PRW_sFnP3>Y(MOO2_vxvy>~XUM%G;`Y5+si)$y_yxm$aHlCLGpVWQp=l!Vr z?0*dmtpA_h(%tlLMopph|EFv3HtoyI`Evg7|GW#~y_5Il0Tb@~HMJ2J>d)@&w@lv3 z?PvXJ)embd*`AP4gZ%AgW=4=I$A8zFW56stdOnDCu~(`7<-Q;TQsn9C=d#Wzp$P!o C(#@a% literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..dd2f280b69ece0974cdf184de4caa12ff5e9d270 GIT binary patch literal 229 zcmV^P)>+rh*z@-icFKtRBv%*l_Hp#&BvyhbpI6F`J}sF`0nnU z6aG#Vsg)Jlmy|;Bl)4@TQ`$Bj048}e16Mb5<3fL=>EftU_>4?F^-H)lsY5C6$ldH` zYZ+z_ovnYiwR85+&PnxasXEzZ4~>v~EeS>;J1Z|k1|%Oc%$NbBf;?RuLn;{GUTb7!FyL{y*mv!0 z-j=>;VgWB4LpuI*D3ngw!N@SZV@CQHsm+nb#hfzE-|*O6Bro1*WUa n2V|83zW;JeUZe4oVFRP}w6n_hh2uGarZafD`njxgN@xNAC0i~a literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..175037d7dbd1db508d5399bf6a2e2df62570d14b GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBTs&PILn;{GUSniqP~dU6n0N1@ zxQHNc>kC_^w*EC-4oXU|EYyv!A9<4Ec0u0pZ_>I?3+6@@H8uR!aqGUAt^eYzznufK Y!=A+T|DR4T0h-6)>FVdQ&MBb@0DTN9KmY&$ literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_launch.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_launch.png new file mode 100644 index 0000000000000000000000000000000000000000..43f6b63a2c5d406a382eca51ca7f831fbfed04d5 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbB_ItWGhEy=Vy}psR*+7IXaYhTT zk+W1=QK}((>LzyPgM8C32~E|1^uFejc5dw3c7|!>^(0apD2RUPAQyZ5tu`pBsm zR!%tb!$zZf;{uCg4K`{O?KWy0pJphWNdB~d)xuV-Iz|7%mlLTDH{UZbvNw z_7q@kVXq|YE#bPJ%`j26ipC;Z7Xx4geqG>R&r{Pyhe`!XU7JYhv7oWl&{+;R5Rf4hT7!>`BnK2ta_L z4}v|0w+QH2g+Zka)l^!{fr>!#1?eUt*pN~r5|wCUCCYuy2~&b9-&~z#(EtDd07*qo IM6N<$f@$TPIRF3v literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..4233b9de2f788a964289b89eef5221fd5b83e07c GIT binary patch literal 391 zcmV;20eJq2P))hUY+wQgEQX|N1Oj>{nT&kx>unzy1m6Zu za`g80B1p){*>dIrz=bngaxxM<2UAuY>2hSnR3R~^5 zO{$@ouwjf16WI|_F;+!n&+Z9fPh^{15lUqk&YFW7rjQuz^66Ix9y9}1qKP&XDd z_PN~8hC>BTY6Dd!$sLH>@ zF#`&|d&5^S;8=7zO7vOad*c^j3;LAAh6Ymz$BGa)r6GTdj2^^eMM&$B$@T48aZX*9FeKzsVt6D$sa$P##G%Rz-(!5bP$;#!Eq~A^q#_|SQK;RizI3nW z_?i+z2Zh>?b24$b$Jdb%#;{&=syK3vZ!96?=(L&ZMjUGRatUFA-Lp6x;hRVZU)X(! z!#=*R)r0rN;bU{b6MRSF@VvRB&Qr~h8`2Cv*BIZqI+xh56CHf_;*gP(VkjU@d~?d; zP$$JuGAnqL(r;@LN?V*!6^ETzw4p5~27#e0p-zrt9|W|~rzK0sIOUobKJBBAHtZFg ziNZAOCC1x=mdJ+2TV>VzUb>FiyoP6!KTp{Z1$C+PG2e7m#7$)s2f-t|IkIb-PYD`u{ze7 T^ZU@l00000NkvXXu0mjfM^Y*r literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..d5aa374e93422e3ce3bd59e41d521a7c4319c318 GIT binary patch literal 230 zcmVvqFH;e+k3CZXA=w-vo@26)!yNmty&BsBKT<))1E8BzW5J6 z#0tjdgL1+=JqZ9<7B?L-AmA?=(#*KO|&<8LaX71dh zKgEL7LS?ZhwO2V*xske2RT;F&we=lZjHv02F}bncs4=1`8x?lIi8+0`tgx)<(r3;I z*in&%K=ikUqL$Q^W5S(OFy|WGnaIYF=uDvu1!tn6K4Cz#m4PkMz%j`6YHpA1Hh#Ac>AE8R zOY2x89>r!??HbF-~ZW+5}FUKeIF00000NkvXX Hu0mjfIl_JW literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..0351aa8d861aff64d7868866ca9bf61a96a640c8 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw0wgDV75oXLR(QHNhEy=Vy)MY(6ex21;k9Wh zkrKy$&daxaGR4^sMC^Nd`f~ft#Y?uU8=^ze%WWn>QJ>ahtF9EwrzG*JCiV(^YBF- z;bqrT56#cK=e6Uy>E|76v(wl-GE(B!&iW~qe=T>7U1O^LhI?#czYcH;TigQK^NC6B X#Br|j_nksOH#2y;`njxgN@xNA&ud@@ literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..a730d2f0584b9ad1a45b1e78322ca5c01ace6a5f GIT binary patch literal 230 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw0wgDV75oXL)_S@)hEy=Vy)MY(6ex21;k9Wh zkrKy$e$s89Q*=TTL~O6A{AJKR>Dsr}%#%M)|E&3)oBw`Z^}84DYqwsDVovxXZoJ&# z#w~}!8%GY#ezak!wn+P18%@61*3N}Dj?I0vVWqN2``SAieA2h817)@qG(Os}mi=g= zvAPCdw6q3ay?UXJ%-<^q=WEx^`NXVZZ^S&^ige3y?*~9^B9>C|D+jWf>FKOp0BwV;I<@$+m}VtFq|)2*YxB zcJU5Q^OVc|ox>M?|L4R%Jf>}{syQ3{<-I7|H8mb&!KE&sG*C_f4ObSkVsbiw5CnAx zSumUWfToI^)q%R?P(>TSjmf`J$NE|dW`dhJN7{f#GwF!U-|wE~)Hf@h0Oj=9^c`m~ zKjjX5%Z<)d2Yq{)67me%=EJKZXeCX_ry6X4i?IB$~LX$tMa1 zJ|lx7Sc?|yXz7nuka1fiXxa}e<27x>F%ld(Ukc+7w)mqq{_u~tjv_3`AcMsSEBj|C gjfj01jfjy402ttq47+Lr8UO$Q07*qoM6N<$f?3O@wEzGB literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..ef03dfb0911c0ded058f2674637918f262d29ed1 GIT binary patch literal 224 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8Lpa!(h>kP61PQyJNq4Fy~`Z_jAl z6rRC&!nUdD8z1+xj7BjBz76FW3_2C50;?Na8n5ng`15e0%FG=HH$FTzyEn`%E+&>Y z-Dq!(#nnaX6F$f)GxkTRIF!f=F#hxxH0EyNW15^MyfB5kcy>mr{{@{5>AAv;DoPW1 zJXWTi6>`{p;`N)p2lcDOSzd}|k0wldT1B8Lp5>FS$kP61PQw{kX90Xit9X;PO za8*rzC>3nkp2WZCp$M1xg0K_Y)iy~lj4>lLd+7tL>DyRslzbIqGDjmKF$c?+LB5w$KY>J0Aa4-%+ zLqY{m6hIs1Np?-MTSc~QPHR`+m$}x}yV>=vXP(QPEGk4)&CFdF7lF#CK#16Cvw@-l zzJQcr6#!a-!C2i zZrR#E2K+DRkU4mAj=1*h{K@zCW}+(e{|Rzt9T@0EBh}E#XOI&z@NZ{}^N-CwpRZJv zW+O4J^?9BkqFD#o0D$p`oc&}XY&tXRE&S(#r)E;p67pfPKrb1<4HyLg2nnBHqh^P)qQ55Al>G1IO{_@#@eQmb& z8<~l`JQG>#KQ5}d)FGfSkAmuRu3w^FOuah9rbM%tX3dDFYqSx)>@1_NGAi0=^IwEWiK& literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000000000000000000000000000000000..c471afcd4eebda96ff4bbd7abbcf3abcb83872a1 GIT binary patch literal 112 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|b59q?kP61+1#%Ls|Ns9lU(lxb z^}phQeyO%IY)NijyfdA3oo6U4`eNR(;ra`?8!e0L9|%9uU|{$>sUZ59Tv`&)2nJ7A KKbLh*2~7atZ6zuI literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000000000000000000000000000000000..b6a718ff00f20b9cb4f118053cf9eaca099fb345 GIT binary patch literal 113 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|3r`ovkP61+1w1`X|NsBD4md2Z z|Gz+h|4PRm2b$@>FB6b!q^?~ zLa1v>+3oX;FMr>DzO(74;bCjz+`B;cV1gSrGyS^*=K669C^$4QFfy@lU@-m!Gd)|s zX4@3ef-S8==&D*UM4$#}wwbTjd&<`HEp~-`pS^54+nu?qLH8wN`6cFiOW5c5^8c72XTZrX5n6FT!8XC| zK)u;)xebM~EHZxgot9tX|G4l=gYeq(ZKhWzevnR;^_G8Dwk57Q=(0|ly-rwd_TI9^ z%ObP(ZdNFBv+7A1xm#>h9~6J%kH6Y}t$6x!mY&1)GyJygllGB+x`_W^((AqL zSJ#S7i+X?dS={ku=Zm-AV7jy3?|A-phQm)^ecIu=BQ7>>D@XL|!`H1*d-2!H u>&kU6zqqtkeE0nMJFi_6#f(G-hKBd=tM0e2bF(T2$$Gl_xvXbP0l+XkKw4Oa( literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000000000000000000000000000000000..e5cca64c17c915c6cade93e9f43b219d252ca89f GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|KTj9OkP60R3B?Eh|Nm!lc+=1# zI^l`Pg(oZpQY>K&T(2fRd3EuLOoyR{e1G>RPs0<9HKrWVuN_TTv)JSU-kEkW&%Soi hzp`nGO?M9u1H*m^xi5=L8-Xka22WQ%mvv4FO#r>$EpGq- literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..d1b24e8d1c698ed5c47b0ceb823743c1d3e23357 GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|Pfr)ekP60RiN**2|NrN6SkrJM zoS}1Ko&d`g<&Uo&nZ6ivJd|ZQV8X#zP%SLT_toPD<4qwG%hrV=Pp4G(emWrcqr|0B dOtO-JLA>#?Xz_WCD?mdTJYD@<);T3K0RU*1De?dS literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_launch.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_launch.png new file mode 100644 index 0000000000000000000000000000000000000000..cc7e1deb89bedd2259f04c679be8ee2dbb710d53 GIT binary patch literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@RheN>3NZkP61Lmm66R7znsMWSYA) zICXK^lpcY(8q6%~Eml~?aXoqNGN)!z!R=j)d<&S~Mqga6vRAuV>w@}1$0ex&GLsn8 zCqA5@xY4YgrE{-y*PefCET$;VV-Y$s|Jex}mNy6HD*w;la=yORT;`JC$5#$t7k*uJ dV59DA_UHT7MD>Vme-3m6gQu&X%Q~loCIGiAMXvw= literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..cc9962ade7312b958dd27d3341b70ccee417680d GIT binary patch literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX0wgC|rfCAH#hxyXAr*{gr!cY|F%WQ_E~9vS zg;)UB7pbe!Tf}E|KV@^&7swaq(DoIU|K-qAaKA-vcnR6#kp6kgLckl6FQ#$rT zr>e)Lv*ls-D~APc2|L2|=k4gSn&-g1@Pdt-_P6zZDp8(3Uw6HJpLce-SFX%P@4g*6 z`WI9WPkOpG`rf9RlGE)f-nriVYPC}hR4!F;bt|r2BEaUv!^x=Ox?b*={sZo#Z+7YZ TY2LmU=v)R*S3j3^P6^isw)41+)vE!?GLi&%E=QCwvQFOX5fHd!Do8&J4Ni(Z_H zC_V$ym;6ekC%)kBw~GUw$S)DkP!}DZghj&RN@`D_M4&%bS8A-Ljw8@QFv8<$#s910 z?wOvvu}O@zd+%I%HQY@{+{@efg$EXqF3S^G@)vGq{3*-xp}b+npR>G_WHbIE$x>|T x!KAMehC~`qVKKy&)XPAxA-w?g>eWmCBtFvOyW%Dsy~O|k002ovPDHLkV1oLfYl#2= literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_replay_circle_filled.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_replay_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..a5ac2da5de96154f7af86fdbba3899212ed83018 GIT binary patch literal 358 zcmV-s0h#`ZP)q|hw|GRNy#~)xKJI^p#1S2jQ7~%X&Bmd1Dy*6le>N}Ym-oc zrGv1NSvj7HMCCDnrQ?~(-cNYOD$l^y@f0#|hi9hpf*5C+x52ZpJcih_M$_EqK5vqG`2O2Oed(374C1(*0E37_X)^yu@nYr~P8H2yKdFR9dQ zS;xO*7zQ>P2Jsj&DOHUGprQfTC3@);z1NXf=+hqn0LpY>LdOQ7T>t<807*qoM6N<$ Eg3X$rSO5S3 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..c35065393c783632e9b7a3892bc713874f62163e GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+0wn(&ce?|m!aQ9ZLn;`PCAc2g3k!PvJFmKC z#eX1p`@i&qw$ZQu-}XNRvTZpun3L}P|6Qo9(Z2D6=B*#`l0Mr1?{`E`eD#0vf9?O_ pOrD`NdXN6R=WCh6bC{WdVd6yX*zlziwm=&gJYD@<);T3K0RYHpIz|8h literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..1d45348eec7144ee4078f0f901ba3129116f494c GIT binary patch literal 193 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+0wn(&ce?|mIy_w*Ln;{OUNYo5#K6OPVZ~CJ)fGbgb5@4cuv>DVka<@C*0cJ6w$zopr0N;5{nE(I) literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..5847a7e79a3306f80bd627435d2ed25467231cfa GIT binary patch literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX0wgC|rfCAHY)==*kP60RiA4z_f3E*I-{ibZ zjze~V%p9-2|KW$Un3=w^m@rSR`8D5TT^dKk@AwXxvj!iY?ALxdOYp({|NH*GS(5G6 z;QhbU^Lh`%{r}>B(w8hxYuWr#JfUO7qIf06IX0T7B&MXET*&Fh$Z+EwpTxE;ueSrO OW$<+Mb6Mw<&;$U@&_DM8 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..b89a9411fac5314b3782c1b388ebdd8620b7a29d GIT binary patch literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX0wgC|rfCAH5>FS$kP61PmrW&^0z_C7=FH{r z$zZeL;LZ3Tb)iM(P09j>eIJDx>UYffT6euq<=>Bh46Z{HquhR$6gMY98% zm(5LrE+(&M3SHF6&+vBHyo)pDu1;pc;kReE&#$#k-0oev+cR(b^S1|g*iNe7V_7DV X!6$KV<-+QnoS>ZIBM$^I-FaAIO z|NZ~}fB7se`y1DscM#-p{rdkobEMdR)`n$w*f*K{)1I)jV?$|+te*u32fJF~MW=cO ZhMH-YXD^v669{wwgQu&X%Q~loCIHd`N~!<= literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000000000000000000000000000000000..10bcaa32677cf2c0857d86754d9c27fd6c13b406 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+0wn(&ce?|mGCW-zLn;`PB^W;3|NsB@{|JVF zEAk67p4Z1Ds5@P(o744evDUAT{{<%4F;8Ng^t*A7{EV)u|DXSa|2WSSQo!b+#Zb9- zM%SzV$C*z){(qmjlFQ-R*Z<#72D#j@I>e_g!_vY$c}K=Vdj^L8M-0+Fo7Fb}?PTzD L^>bP0l+XkK*`PuF literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-mdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fc0243bf4c7cd7e1df38813a580746e19d812e27 GIT binary patch literal 368 zcmV-$0gwKPP)Yc}i1wh%m%^aC1%HGli3F{dxU627oALl??#F zKLXGiGUP7+W|c4h9|6eqBOn1REiGB>Fb->i!GnVXcNyQ=-VOzT#gmg0>n{U{wmL5$ zcG4G+05!k_5sX~?3wC$6{JsQ0#GbRWGZX1EvnSE^s6d!u>Jq9{th}EFXst^a)wuN6 z*Vo-dBL-+Eg07o&qJ?PlVF06=1%RBJKBDS)X|4E!h)Q>Ncg;QxAX2q^d)vt`0d^28 zmX?-GKV%{SXsrfrp$t_-)WpoqUpCQA9w1_i{0|ORy*0tLld<7joP1$ZCyiKVFc@t3 zUYIhgmTw7D2~kNh0BD!?O`=Pc8C~D?z6pX=>ILACs6SCbgx3|`}fN-4n_oS z4(rZb*tv2dQ-}YBV4{*#IT~X%`pG73|ZdKhaFFqW3mw0_yE0=Q5 z#FO?-)x1^H|2$u?e!B?Mx;gAAO~>@#{BY&A<+Vl#|xO*qPXOo`zM>! qn0X#mpB!W3_vxd>&qJ$UvF_aeHEe_X?^8hY89ZJ6T-G@yGywo0h&Mn0 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000000000000000000000000000000000..36da4e63485cabaa183dc2d10441766502b5cf0d GIT binary patch literal 113 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj7M?DSAr*|t5)uYnZU_JW|Nq)? zMp>KlfqaE)%a%S&biVeXV3xFBF?U<5YU1HzLT?qrLlT+-4Y(LsB^TOGIQ5keXa<9) LtDnm{r-UW|8h|92 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000000000000000000000000000000000..fc4f4efb9535f5f50a6d9885740516495736af0a GIT binary patch literal 116 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj)}AhoAr*|t3-~2i7eDy_|NmD7 zAML|i8-IMSS6HxAHSzGVNgo#GaV}$ZKJ~$1m!zGDvDBg!3+#c485oY;Txi?W^==o? O7zR&QKbLh*2~7a`&M1Tc literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000000000000000000000000000000000..8d4b1337d989ae422926910204c679f9cbe129ed GIT binary patch literal 855 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7+9Er6kFKIlR(NKz$e5tF)=YHC@3r}EFvNz zCMG66K0YokE;>3oHa0dmI5;#kG$A1&JUl!yGBPA2Bq}Ouwm}QfB&J86E{-7;jBhSD z@*YrNU@+9lImr2MZ(>7>`OYvWcA(*eK}$_J6AOocfyCUjM$tw;^QM{KM4HadOFEY3 za%-#Rq}$vjB96-1dGkLk(&XUj+dj8y-~D-ZyT8vVe*aT{P0rdgiHu?DLA51DTisY9 z_FN7>`9g;GK<;bzr?O%WAN}^vR$&xr|FT_>&p~nW`e?3(>IwE-2iDwRc$>s5Vq|fk z@dkr$6Z4e^45|ki%^rZ*I)@o!_}DW9WEQZoYjE*haARL_kTHgfJwse3p_A_e1A9Re z^OP41xg`y$RtGF(7226aSlKg{F`HaqjC#rNxul`i%AuFfz>i&G5pzmO!yLW|ml#Dv z{&5~){&GqDVBRmY8?N>o4;ig)es{VgoaJ!tvcjJzhwoys&$(7+UA($*f|gipFPDa+ z@5!oHi~HHX%-DGTQ0xaq%ek{Qem|&H9TatP=Em)fcHNui{IOPfQ~cuCqBmN96nN$e zYDOtYh{g*bYpHfoTTSEAl06(elF{r5}E*x*C#Ik literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..6f7959f9f87f97f4a22d626ba257e8b1f011e698 GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+icuyC{kP61+1uQ*H|GAcA?eK4! z_J7v@%m4qYN1gtE?!W*4^9oUi|3CS^{2%v(2TT4h{~tZ^$;mdKI;Vst0EYk;Gynhq literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..23c3eb55d82808db52e196256f875d53785ac4c4 GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^5+KY7Bp6QcFoXgrrjj7PU;ikY(=ypdtoOS3j3^P6B>Ar*{or*0H%P!Ksf`1GLLWgc6@;io^F+@C}~h*o;1^Wcxhg%g<<4+>fpOg+n`uP-&B UuxnT1E1>NRp00i_>zopr0C;gfH~;_u literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..cafa79d92178915971ac1f48d47b5acd2e4e0e16 GIT binary patch literal 282 zcmV+#0ptZyZPqyGRc=r=KZ}^JYW$L zlTuKS5))$GRlSL*m{>DW5%IpN^XNFD!#gi?s5nD~b4KUVbCw?Wzi!|P!=VzJo~!iO z`@)htlzTd#yZGk3>$pp2$V(zje14||pq(QV(NYP5>ey z1WREe7A8w!CKhH(VJ;Tt3POb^D}0JV+UO*s|H(2=y&>vFrF{(f4<*SJP gvLs*U0oneEP)5T{V<W|K3%@?L774z{k6Pw;2~$ zgv6v26r{w2SZ7stA}R*v3{*tikLo-cOf-1=Weyd4sBre^Tw3x;?BXmY3HZ4bK zvA2aKXDGLHK4#kvq4__pjmAWKiofi+@NJL9vy(Jtgv%pNf?Ad-(IKtByy`S|s z0T_fMLiAuNOk|-DJ(&tKStvx$rq6O&C`8W{gbI-rK1HEy6bf6R^%RD~-{VVRz$&Z& sg(ao1Mimyl!YQI~Ea{(f2M*4QADH=p$4De>GXMYp07*qoM6N<$f*wA0z5oCK literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_replay_circle_filled.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_replay_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..e73489a571786595b4a0844e380255d211d278dc GIT binary patch literal 452 zcmV;#0XzPQP)Rz$z}CjuK(VFpd)(f{3DWH!M)HyR?F9F1`M4jrNR1O@DbZociKInHN1eE@kb)v> zlIrPow;}GpXGq0>Guj4Qm$Wc+Bs2O{RAgMs1}-slBmh_DjjMo2_{k-%AGRWBxp}^B1;xdg~y_Gv|u% zKfUzkFbF@W&}Zgt4)@#FSt8x-%hOmLDjNcq_z_kTOn z#Ur;T@RaOinBcfa{$DxM;dHjlg-d?g%gXS7VOq?<@K@MH__)N5dqCS5JYD@<);T3K F0RROKJ;49~ literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..10448d399d01fd1dc71608e03301740aa4ea5834 GIT binary patch literal 218 zcmV<0044v4P)%)&FqKt?laV-}ujWr~mx^^kp1ZYg6N^o4?Hp_ByD|ifnxI zWsf@RArD*a2|NA56nL&~Vw#zi%Ib9AKTILHca4MDuDDN3pY=|wtHzuCJale*T%E;} li2p7HZ7I)&i%EM{-Wl8SQzp~G~&+)77H~V??-2AwI nmQU`7|2z0hbwPke%wf4K@k4d`OBkzw&SCI$^>bP0l+XkKUGGbZ literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_speed.png new file mode 100644 index 0000000000000000000000000000000000000000..040ba0ab69029385f4a3362941c88bb2d2c5207d GIT binary patch literal 269 zcmV+o0rLKdP)_lV}7 zt)5}{i?9CW(u)%XLC}T$P>eg>KOFMxpeqR0F&PZNtg;}{ho!(Cqwbdj+o3eoIE=gx zKqyT)Zjx|FvZN%M?!tkh_{JI*Hxkd{!f9Npj(!?R9A9$ho%{oZ7hV7LqFIJ#kGVF* TJgVJa00000NkvXXu0mjftj~JE literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..eea21c2ebb7877fb4fc8b6a9b7181e1749e28fad GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjDV{ElAr*{ouQ0M5FyL`}D3@;? z+81`ib6azbv_$izfK$IXIx18?P2U-ja9s6#>snQdK*5u%ETWk@Cb2Nu3qJVpRD-`a zZaae((^uJ?FGfu-BA$vg{posU^{Hyh!$fntb<2|qnGXt-y)@nW~cL!3=RSu?uCt?Zv*XM@O1TaS?83{1OR!FGmiiO literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-xhdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ee42e2374abd4168035d0022f9f0e4fdd7562bd7 GIT binary patch literal 658 zcmV;D0&V??P)>Qv8-o$TU2lc_k9XZHQlXY4vtb*ihooQH>p zhlj_1g&+v33Y%RPv;m+!0Q#jA0SmJxnM^kNc^ooN5H|amxphhq0BB?U+`W7EDq7}6 z0PTo8)Hr1b#6d(g)kC`Ie##JtL%pEhP_L+WoF~AX7zqUW*9ZK0UUi}RP@OnQAQE#u z%k)@;&RWn?z+3{%qcBXg{Y&`FO91*nM9rNdAe=~#!lmV(*F~hEfRjIu!;p6ZoFzi9 z0a_`{^PC}I*#z0$BSJRvPBk@L3FtY1-|IFc>M2ZzlxUd~05~)Id(2cYJtv~Rdi*B= z#nY!xt6R`^5i_ogJwW#X)Xmp(N+1R$phT2VnGhhNP5?d5?!G;H_EZw)8^ZJeKzo6C zo85gHN*YocT4fMIv{r;4WNSRkQ)V8jnF^ri8dkZzSH-O&FY$Im2|x{pd8FGMjJY9_ zIlQ3n)-)?@PKrp=k}KwzzT{+hEISnn85tQhR2L;iK*W5?i9jqn6$()ZRT1WGWg9}E zV(5OKFCBpM0RGa{PljEMjKFmX<$HNdw7=Ao7wmW?=PDLCi)KZ_M8}zyw3iGja@*_db8&RW5HRFi0?RL4kxqE@-l2PLCdQb~Nx8?CIWeUH0Jx zvr2XrUeK>(`(gnOb%9!maHvf(4z*?46v+4qASGr%n>GVt(y)S(1+B`{Ef$oa1SQjl zZ%iq}2$n7qk#OcrLgXUL5P}q299G=3a)nC$1+n}4I+2aX{(_+$CDci5eCRLe*ilBE z%*Hz(AW$!n`Xs9_Q}vCe-zxN*oqoI4IdF0$6mr292}Uj$2flzbYsrWWS+FZqRUqdl s@P`8;Pa(8w**u+d&J-Id^exlA&rqCq3>SxZ#DCMTkiZQQac`>eC^7Q>NDRqSA2Z*{mKt@ z+kXQ2jNVtA-_!- BIcxv` literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000000000000000000000000000000000..2f47653961cd02426843cf71b6b3daa0e7a4b717 GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUt6i*k&kP61PHyGI(90b}f@`aXe zZP;5_+HCKr*wS>en&m)E#`PR_h6Co4wme>K{by;Yblthw)gl+vBUicec!mio`6(US z?~-#>Sxs;$6VH3~Iy<-6ls5A{t;f86)~)=$hKb<-Uqq&P@wSKRZ-MqPc)I$ztaD0e F0sz&OHqHP5 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000000000000000000000000000000000..201f6ff580f113626af771e9d9a0d082769c754b GIT binary patch literal 1892 zcmc&!YfO`86n?*tui8t*f{dF(se3!E&>oU6UaQhlqT?CMzVvGl9E^~R!mGxLPA1ZTwG#eB9F)8 za=G#G@f;3EAQ15R{OIUtp->nb8_Q<1IXg#R0I=v)cu)Xa1Q{Ml0miff%0_Y^o-h_w zwbiKcD#n?bf7s_?`A_-@F$hcn?`K-{{p#Pzqn|*N&fys$m^T?f|G`pLziahBI{Aj5 z%)V?5wJIJK$*v5D>;~yNukZG(yqY<9HS>VQ;H?JRR94y1J(}IApUr4qd@H%>Iop$a zrmx_0%RoRj?H`2xJ9vy*`<~99>b4Px^n2qhC=Tis>a;iE#!bh_6pL+3B@V{1s%QZf zJ#nnV(il8=8M&jC1x`tFUncH@m>p4((mw@jRX{GLH$?u$r&$B zQXr;H4Gt}!slbnZc>PjJ3rPEAn;V!c?SU0_?kd1>Sx>@hR++-SD$uk9o`b^Hg7iXg zM7s;8oj%S58C6`cA%;c5qNt{9891-a!)a3xCDOANq~PHuxZxu(Q_zev|$Kv6qCkf_}3H zbO9phQ6H!kCI~lBpdlh-HF#~(E~a3^yQwgo^Q2BE6&tCs2%6*vUD7Z4%VzU<{O0nm!7^V;bAQ z2Nmv6`89HGWj+DZ-N0^)oxHyM+NpfcDEtu)OVhUWaE8mXp;Y)%#qP=I&XM~C?h5Mu z)(}J`-T%91W~sx=w)%~*2XfkCigidcnha%_{!iqr&)XQwt_`Ld7j$)dR_ZP^YN*$FQDAR zGlepO-ImFU^n;=;vz9=$?n%$8{`MaZv7sUJiLiY8J94}XRO>oyc6Ivj9DKYT+pM1Z z3#Uc#ilWhs#?FsP5$%h$rGJ2|k^wXMGCds=W4A{t_VP;2`EA?TL{UIxGq2Dgm=B+Q zT#~I0ih*7tvLX-ndpnxW<&RX!hc?W-hLptL%!E{ZTeb>p1V3xfPsAdwetomuCGnB@ zG(E$;+bNYE5)wM;rl?EumtI_*v7A~rzwz!)4Yjw9#93kg`fR$!iYFEMpWlh5eLZq| zj06nU-}g2koK1|Q!+n_$$FKH5+3=&w*RMr{%@4R>ZO#5}#ewQqlK5Sl);BP!Ym~I- z8jYOz&<@W@e>x#s^Uc9|$=&8>&I@Ncs}Q#MX}iG~D`_M;FZRx|G&UF$j0<_9oQ~kp zOS!JCEzF8N^F$lT0*^MLc6{tRCH}PI*}$Pd`0bSrFbWQI{nNSr@SLmaI4Pu)Zi@q5 WM$%xlYN71yOyR+dpo?3Dg?|IXM;n0v literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..5a7c06f0fea90be3b6e402fa74976fe14eaab672 GIT binary patch literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}b0DIi4<#Ar*{ouQ0N;1PHJ_a7eKB z6bRUUTiIoo-}V*KB~45pe~1a_EZDf_r``wq{Iq{swG)H9?p|_|fb^sd^~`LtLbIA@2_R9U%Rlu6{1-oD!M%4a_?vQZx-ttoSqilnW#KfisNH z?=Z3^-Zy(7##Hmuqd}+sICIKRUAcr)?$3e^o<42ZvqxmYba5u0N<{^IeHM;Sks2JI zoEj8BOecqkxQHajIF5iPtw41G38%6d56qfg&MbGpTK7H6l#V5P!~&GAsWu2CTws*F zz<8)I{OOr_*K5DsX+Hd%*@jtPZ7%l>_9w2hmSxtZgmxD*b>?dWJ;mVZ>gTe~DWM4f DJCtCJ literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..06f936803f921ebc10a373551a8c5f462e1880d6 GIT binary patch literal 478 zcmV<40U`d0P)kvhO5WXFLg|2;XdBg7Ls@Y!%jXQf5*PmQ-aapJPRpRtBbL zaf(baVOo51Q%p$li`~5ord$f#MIua!|^CL&?Lq7J=j12iE?H zzj#T0+qHuN000;Sf&E)k;~p4dQw2XW{-Neyoo^O=yQLde-}3XJ$&a0WaObNJzdZ8m zH^0F2D`3Aw_h$%y$nmEze@yh}Sbxy=kpLg6@Uavh?D5ek6)pqtJOFS65Ye^P3b`u) Uup9ung#Z8m07*qoM6N<$f=7bSRsaA1 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..c978556298f421385bc5ea2e5a2bf3289e574d03 GIT binary patch literal 509 zcmVDsqOvm!FV7oZ58D>Yzu3v8ot9seq1ifA~t@o~G9cY@SE8^g}vmR{Qw|D!T zuwM@k@1NIbG#ZTuOgi*&88XDBPY1IeVGceUcHH^z&W;T}&a={7R$R&B$_iI!nuX61 zRY!a*9dR=vii((38i_}URESqioJ~Mk0_^f%;!K&&rM?XlV#*dXDJySIRpy2q3#zox ze?6U7WQ-2u;-4F%LtN;iu{oj330pldK&L=8&qFs)CC?&6w-76T;FM>W=D%NriZ8av zL6q}`_^A2djc??@&y#0zPz^Nx@W~HS2h8k17t{kEcxQ{0n;p0;7Wg2anq^jAi%ao9 z`P5u!r3X473lvZ7$UitEP&jq^C$p9Z3a4JD2kzy8!m0OZd2Md;K;hIk+R1@kkOzvV z?ixwx$pht6(=(DVkO#`AW?&>?BoCBN&FF;#Yd>-ApX9e)J177EfI$%0zcn@Pfgv_k z@H68dYW~&vX2G{xx?%M#KOdU>*y#s%zWVUXBfoz03rxQP_DghshVX|Re+u)*M1PL; z2W=k-@SzGHOYy-TAB|GsG62s507n23U2ClXcC&Qqx?Evb00000NkvXXu0mjfl#k*9 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_replay_circle_filled.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_replay_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..e700831f6ed602c4b06e6d81c8a70648a9ed9f41 GIT binary patch literal 851 zcmV-Z1FZasP) zEOO!+PZ1DKqOV-%rJ)MgBOPtudvT3tVv!AwC{U%3o0katqT99{j3v%!;G!a?LB60&khFy18Z;3xHQ5pa z^R%$LmSFs%4Vs9U;Dh54K69I2VRbH`i3md?8nehW4eV0-6&x6mVVdS|k@LtanOo3B zWVn+2gJt^I+)5B0K^KwXI_9QB6`Mz~W0DT&B03fZ1#CJ@iVbUM-D6X8J}s?@4V(D$ z$*c^pj@_o%u!B!Y;{ay2BQ_l3)1GmRwL6R!a7qT)8&|*`?B-;EKAre|Ow=m+ ze~#6-^8cW%QS{ytpwQ#gdsBtND{1d-DGC)5o*R1<>SV^*8im)^DF4l6aeJQM2FEe9 dh{j^E*dMcJm_gkM-;4kN002ovPDHLkV1h~Nia`JX literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..d7f4f7377d14599e7222e9f38b8e049935e14cbb GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}b0D1)eUBAr*{ouQ0M5W)N_>*wpY% zrIF$7+o~3UoqqWj?MJ!1a8!6vct(5IHC8)tpF zHHUG7w5eqG=Gh0^+SSTZ=1L}i@GUjlZ6;yD{3}IoU44HP-beMRo>x}ez_ RK&LV=c)I$ztaD0e0szK)ROJ8w literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..23358ae9cfe17288446af0238f4b69203cbd4639 GIT binary patch literal 386 zcmV-|0e$|7P)LU_;@7j}fQlkXVecP67tlO|m<&iIX_iZ|})HbhcD#0K4+#9h*I_475U zpr$U7ljz5E>)f139EhA7wIvoEbrw`*q9CJcL0x`APFYVB^eD^aH@H#O5(O>FZYB%Z zZ;`=IHaMmR7mXxrsW2#Z2IbnI;TW_`gQjm4Ta@56;e^kTu@bzjqN9EIwU6)q0mOcU gvL6Di$5?wWpP-plchyN&Bme*a07*qoM6N<$f^V{<@Bjb+ literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..974ee29acf7f16902f5e04937d4a61274b8e5ddd GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r50wh%=tki(i1y2{pkP61PHwDiy1u(ci{Fc98 zB~s$}&riDTbBa!Aw$GVla{trQ%o(np*}DUG?~Af7uxsDEb=Uj7Y%!{ylT&F-6=kx6n$}($R*#GbzR0p|7e~a#xt9Ds=$ANxh N@O1TaS?83{1OPmMc{u<8 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..eb08953752a0cd19e1a6e6e9cfb60f25e29fd4cd GIT binary patch literal 273 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r50wh%=tki(i4Nn)xkP61PHw1Z_0wvrY*5`Uo zYAu|jf4}I2X8Rl<;rUrNeqLMTlr7tY>xy)DX{p$B0_*Fb7sdO?ef2i(b z$;Q6v5#Pebo|~T=FF1(BFgh{c{M2;8A#ILkX5*SlhYST_zA0NArrl96nILF=PeAI# z9Bx(J3B3Lt+-sIDRbjn$$&C$y+0baI8*kOW`Cs~R=EuT!hNW_1w~p)buc__(6F2MD zbwB-gwe#$1Y_isG<)86)UPG8%Am2>n^5-O(jX(X&o~&BM zP>ue~V<(qPjPq<}I)km`Xv+%jBbW&_8`xgP7@2D1@m|Wxpq=JTZn!9U@7%12K89O zPe4vk%aSmOTDc+mSPBf}DIlwof9)UkDnxp8yG4YA1%-NSYM0=hfZ)s^oVLkGL zK9&(9_?hsTXvZgHsTIOj8d@wCA@6BrA*@86g)#@Y9{*m{=LNFTLb!`evj6}-F~wpq z8@UtinDRATRtRhfeJY_LUyyDV#RBAr1(Azu^fn9Q3^LQA*n!NnAVfYf*@BpX?6)W` zA*130#D6MAA{Q(xBEnfkSLAMq1BA1RM#u;E0Pdg2{U^Jpsehrxw2G!4twPOCraLsd zNV~4@(XKUcWVy${(a4maE+aE{=)-QOp|7=t-EM1(|KjDpI{Gia{_DH{0g{WBtmBi{ r2_-rL@3|a-BJ^V(JHlsd`B&{PgyBbgeB=(#00000NkvXXu0mjfkwE75 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..820c7983fe3b1583603c6a3fc8fae0b26aa3f6f7 GIT binary patch literal 214 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtxt=bLAr*{oZ?y6ra$s<{c$=B+ z)uOj?89Zq}cKn{W`N@8ZD|LIt<0h(bN-#5TP}#3~fq8MgS)Fg?>J=Ob3*>5j4m2$e z;1KWrG4nOYfd*z~9s`CK4U+56Ht5YcdVacA!m0Gj1!9Krj~dpuSl4J~J6L#sj{ntg zM0^P&n~CUz2ln}kP3=z9O!YX=0MxqRZ{yl;?P9m(@8l*`#7FP^LGlKH1U%KR;SyF#X*^^0X0lV5eb4J+@`#*i;K0n)8(7|6eRwZFc zpP^7d7bBZQ!htk~Snz^M*!c6n9bd-C^i(E>P0wGkOC&h_o4Nm@ zkoZjh&gGH`4UBA8;&W?ikk#^>8_Q1y$~##!IQ9K-|Zz6eQLCsqy4c@YrLI z%}VNNgg&2A+|;87^2DpJzB)(v4|$$1OvsH?xd*_qgIUnip$Q_gdV)R)$OSLI{Omj~ zr~zc$o)9F}qo$V4V8z>txD|6N>Vry9rBazQ zS(K=}kPz?7^KALh0BZ-VEwJ{$+Jw>z0$C!kCYAT?1uEl6y@Zte^So>E(g{+P$ANft zs6$s**WyGRrmDM!0-$<=ea_w%zi&bjh3inInlv6aY0{(_N+XESKc?KE8AAh9b$vpH zl3c77H5Ng^s0k`CF($}FnXrx*G-b)LAXR<{Eg>K>$gXoa1WpG_<&cZL>pP!X>aK_cf9cG;GzLldBSGa)~y>QOu|1$p{$ZQipew~U<$Lk6CEvXC# z^cyl-qj*QD8%0Ak+s?Y;HhmRpxxtih%yj^j9v<2a7vIF92?29i7h UIX85g16&xm3tHmB z(p}I27Y^=%YXj_Vyr|jS++Vqr)ELL>v?c`8qIRNS7`{CLCmg=G3`S25zV` zmkIJ{l2ATK(8LQnXhm6BP-Yco(3-6J16kH#1s$mvV<|dJ*|1?shmL@g-6t3;3mx4cON*;nb2XEuUC(HHvx-%2j$U00000NkvXXu0mjf DaKhvA literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_check.png new file mode 100644 index 0000000000000000000000000000000000000000..338d25f8b4fabb10ccf4f730c98de2ea024531f6 GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!n>}3|Ln;{GUNGckViaIGkpJ>x zqfOKlMJCyf2riAAEl;>6o$hI|yukrf+n}<%c~-ICCyyd)ElYV@Pe1=jmnT2rn53NU z*C%6H^yI`uW%qMsK-#m&v&g^5Zr`NOXKpW6Kk4Z|Y4W5kw#&VXd;=$KdsgC^_jyU> zxqnLvf!Gm<|5{!K;$|rR45SY)xnHdERmRiSebStHD$l>GSb}W(Rm}%<1%v*V%|0qy SUw?fB;&{6HxvXlU+Jv4w^H$A%02Ih2k{C^8G$MpKIm_~Ry2Qs}bJ~>r^7ft+@O{*C z(Oag_tniaX@E0NXIk9tW&dJCNxyuyt0fo9P9(5>Rke=vLbhcWfOJ$PBFegDfau(uzA#UFG3@3&itu^>bP0l+XkKYIjJ< literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000000000000000000000000000000000..32ec519cd13399f108f2499d52bfac0cd582dfe3 GIT binary patch literal 194 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!ot`d^Ar*{oZ*AmlFyL`@6iZ-g z2;HoHKxe*@=sG2rLi0n}LO16D6*4gV)AjVev`qX_iIQlci`L^3CDoaeWi>bf{d~ z;F0d7wDz!4r^=hSJWp3Ymvv4FO#s@}L#qG) literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000000000000000000000000000000000..d3901f6622f0b6a25edc50bfb6fddf066f955bde GIT binary patch literal 3131 zcmd^Adr(tn7C-lL6G$MC@{mAcxF8AzrQxLkwFFQeif+JQ!b=im0Tm&D3gU`aPy_`_ zX#sf}r9PGs@I@7^5On3yBE{(zse)xuAHWx0ejJfWXxtxP5h2nYxd58tw7OK50l zaB#3hA`y$lfq{V{k!aJVO;V|J^XAP#K|vuQAz@)*RynFn0PQBBkhZLmB7@BJ|8s4cjANzy5=5JMlu>BVmT+a%C2^b(BWN_@m@c$&h zslO_XVE($aP5*AJ6fq83fD-Wg7~A)_dY#|<{9t{hUFSVf^0YGC=R(}>j8b;LUxA4F zPx^NHZ9TkC37CTR`>BNsJc-Lmn&o>7<2lV{1KmCQAJxIGv9`U=E& zo<403V}AaI3xyO{Ee+QWKe_i;w6eDf?GC9}c!(iXfMxA2mxjdlfvFjGd&%UR^6|-4 z3=ED7;Ki$zHeQSOW({4SWTH#mxi>1^u_QEl~R1YfCtH{t2aHzN0$Wwj~||-`nL5f9qNlZ z!}jgZLJ8v~dkX8jLh{Ij^*y&Mu4Br8!8y_N*4b>3pmVaGlCnfpl6*0Hc*|HfQ()Kd zwjoqs>G8DXg6g})2sSxebJLtSvNldKJ;p$m+Nek7cl~Ur+}ihW#*Jg3^ys@+TtuCW zOtgT6g(iiN>C7sCmZ|+TBD7>NBusgscvl?h90);`W;nte`b;0{bOYtU)6s?cnr=|6 z(A9-4mE9oyqdo^XqFA^T*oY{D5Gmc&5xR^a1IGv>0$$Lpt$}aEfmc@Kw&P;RKIBCO zHW|>N5F2<%FFQIII!Z8282LxcUeN~96)wP{fw#G%2Jx6olX5t4akCucE6KPv{7hg3 z`@vI`@R!4-0CIoo8A!u{uU%jQ7s(#w7A|m-stI#+U09A0z8xJPwS+eJ2r7)AN;^R` z@C<=^2qS*TjYpJ!dj`GuFMMCfix6cc$hn_70Y9Gue(4%Sqa`2sB9g-^0puHib(krz z_CkHUXgJFj^=Ybvya@vGF540jrA7jWb7W`@@bx|>{9EuGYp)Zjm4wB4vr-G@mJOP*a$6L`fkr zHbg`=3BL?yaPo20f+eWd5l~=E3PLmpa9};`?%IwMep=F7hrmI`nUQ>VSi>Gwe%?~C zjD;p&qui}xes)cIgEi7xKoc*bL*Y|yT%&yTO!_hwwDZMDs!ohS~Gm>Y&n}eG~ZSb^uy7zw8QFhyt z>L9+3h6#m!uh=&;BBtoDkJtBJa;GJB-pv~&1KhYGx&5&NyclfNU(;lZdMBx2lB&8F zk?D4vc3~I)s+l?|&wQizEZ#@w zFAL)wC$07;^_mA7-a}rW{Frw|H^0c<0W->DQWhvOEl^n?#>=$+a;ZK`$X@qn{bOBH;CBRnp~W3%S4=|`ELxlu8Z^VB`e0O7`0Rd8D9js@E9l z=ce7fdx4QGe#Kyxwq>R0??KDzN)>a*{cU-U?O8)r6hpSgj9JCrUg5@;o(tyuGJ^^J zu$Jqob>-=3m4EDGDNDF%tiStQW3XV?{g_0}-BkzaPmrFUh^9I>F!R= zNz_OYFQ~{;9Ek7Xbsu!gK9{Dx9zX9rK65$RVerj2pTF%qD3`HvG~>R1Vmc6>VoRw? z<=;pddUX4UcTtTow>mUzH8%$%|EB*_OL`(*V>MdMNd%F_y&0Zhqo8?pX=3w8+l&z9tE&^{11Z}*z*R6RQd&AbY#^&8_`>9ic zyfl|i`KOtGk*SuY+hv0Xgy>wdDmlfe!Q)n#9z(10%IF=A9S*a~82qlKr9L*1VTiu_ z$aQTN^Sfh;`}Q(cNVH}2Wc4Korj)#BUhOvFVHW4J#B2Q37goDGouVGdENlC6VBZb>-zQ~sy1y<=ms+az_YYjtxpnI8AQ%F-49+oT?Ax?r>mdKI;Vst05!!P AD*ylh literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_launch.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_launch.png new file mode 100644 index 0000000000000000000000000000000000000000..ded2c2b859741f519db5698f3571c2a13ce92287 GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhawetNn%hEy=Vy}6P1kb?kgpn8m8 zfRb8yq{lnP$qZMLxPG*#?crpzym2zT>ZDu!idCyt?eaRU>TUNj?}z5yuSI^9>}pMy zK6!m(k(ylF_^z6HZ8dWv^M|dQ`5(#1AF;ISD6SEBbjGfua2CJQoWuafIm*oz?rep9 zJdb1~k60RY6sHJ0KcR4LqT`&yj}^{sbeyxe$-8dyN19 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..92af7254636edf8af7c2f10eca6ecc353c49b141 GIT binary patch literal 648 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 M07*qoM6N<$g3XBt(EtDd literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..59f5bc33fd047bb73f5bce719c9668af8f761af5 GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P0wgEr{)=Q_U=;UsaSW+oe0y8ar!|qG_2IQ? zDv=V$e}2+!pHp-~6GUvUsgz0!ymZU2$RJzS;{4-JdF9{d{r-Qy{J?ugpgBNLdE(?j z`P2OW^ermSJ`F4QGE<{=bLV2_oiWoHbddb!=`fL fTVswgLwxju?N{@S-SKUbZ$W%dS3j3^P6gxZp9+Ol&A?VWk9|<4QeJn&+ zH$UN0J=k;OW7DLBXLA_6nr?n*n3S+g2FQvmY@U>G>BMW-!gw%`pJh6x72LUKMwSy0?*+; zyVtef>*SI+{3*d!grTFNr6MWH@z3mSvX|$0%N_}pn-XNklc1+qMNt6S*burCa_18u$;y8r9PS6Mu}k~aeO19xC8(su$S)qD}NiMSWPVIPk~et8>l9K z3&mKAG6hlxsUkrmI}1q$%Rp)w|4VSF!EKaHAoYya5)6irgi=pg#~_-C5~|UYB|PEJ zrjj~{Ky~@R&t5T`y5T-f1t#;H-)$v(5RvL@BwjKovX7F9-aO=IaGab$L~1}Rkjw~oQUXFYE}@dCB#~e(Dw$dm z5(UK)Wl2Eo7c)wejG8E6@Rqh>iD4+HDkiK(y$HXB0WgP*mRrCh)LQc>$D#&FSOCCT zyP=~yD$%gkh(Wz1dqjcYEin=?GV=n}MZ#etYPJ}lV0Pp~$2ipP@Z%eYs%sSp&QL%i zLPb7yJ*DfqeM*~)4VOvb zuGnBM$GoSGlX8r?U}cEou3)7lJ&og_q-Vwu>bNg@`d=3Izi{rpwcvmA#sBt{M-4nh zb{2Tj;#3r6jVDcDMM+lKx5-`nNU(ueFMcFYWHJA05fv5XqmS>aFj2nY)|mhR002ov JPDHLkV1kzAPm=%u literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..35968f85e243530c8c644febc0bbcf12b4ccab3f GIT binary patch literal 281 zcmV+!0p|XRP)ST6a{Huh-Ac&8R%dvx{xMGmVg1s0GGn01^R{kC0p-kuQ*vU{<0e(gb<=m=(w}+ zBL_PVdb-9ASH9%t&BeYtA3S&&SBV}kZnmH&ftexGHxpQFK|6t=$piy|-KOIfM4>?n zQjmfaM9auV%*IF?9E+JQXyTYDI2JQq(8@7ua4crJpi_clG1CXl@dyzI#b&;s;bfM literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000000000000000000000000000000000..1770a0d4387db387886ea56d8643c450a460d279 GIT binary patch literal 265 zcmV+k0rvihP)&|7RV57RcYNgKhhk3>4e}3rt<4mpAtm;NS>NBCj+5i800)^tfQf z8%I!Z#e}}&!3i%M%>#*FohKCB$yLGx6=yE!1{Et8RG^}8K|-PiImkf{a?pka!w+-7 zf{i!Z1Yvsn=D^JbGEjL6?_tUhnH4$7$H>Jz3QsKT2La*%`czkTG5 zQGayk57!{qkN6if->DtsAO|_9Kt&1qEG&FDk2PA*4_^X|r{ZIbF~)oVYl32|VJqXG P00000NkvXXu0mjfyc%y0 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0e9df1baa0336b93152767f6468140742362c23a GIT binary patch literal 1397 zcmV-*1&aEKP)Rz=0R*bS?|u~ zPv5$?-!iss+y2;J%US$=GavKL?sSb=Gj(6z>bi{>v9p8|$gpQqQ`78qa-9so8X{g6 zF(48binw2f&m}vfSFc{P^`PryLxv1lL=5jrVW-OH+gre*k+{QOi)MTGko#m(xLmUH zJ9<3}VSN#qU;KVIyH6(KwIM^AmdSgcw*qes-YUFxbd&pJk@&9K_j*3?yddJUZpshI z#PEGhA1EL{Bmj!d4+((6^Fsol`23InC_X50|qm>&`VS(dFYg;zv0OZeX} zw9w*5xNAo}m>&`VW_FzDLB}8f%T~%4WVmPSYY*l5D3wZcN%&X{Es^0Q7yfcSk^mSw zw7jW^767MAjgm z3BWp&D6EUg@T`RY$nr&FeYN>5$sNZEfY*&2IdI+tz$E6ubDkedJGtgSk$8RFV-L6o z-U0CMs_@lTL1S}}xJ<-HWY{+WP+?>^TEsWCN!n;8xrI}WQ;t*56nwQ!X)GcVLq!`r zq+DJh0WjtWzJ|0nYxy1$@6pgA;j#84J4*+?+B%lSaj1w-Nu2e<3op#t&;VqH+nheS z&r3c4ZXtw)WVkC5uan%Sfm;Cgk>RU415oLI8wbI`a8Mc!0M~KG_H4E)0suFsA*Um! zC8wu`>xM?)2GRbBG44=z04mHAvnOaz)Sj>#suCVML*jn`+tnmb1D8tpNknTDJ~y)s zNN(tlJNrAmt2(C3ip#tU+b~*?gr_77s`~qc*F^j#g%fYT{q|X1s_@>C2VZ7)cICHv z(dNaQ7ja(9P0s+hth}-qMtBRzvan~B1=(P)AA0%-bs59u43{-R2un^s08|8?hGxI@ z$5l$z8#q|Q1`S%VZZr89PVyujf!(Kh0CEGLVOZu#waqBv>xQNv5ni-y zR1gL2DPllFC*f##fwaE${{2}N*7gD|12%5xZm1NtmGFNEVU>D!LnZ8$3b8S*pcf6l z#u%4Rg;;k<5yaE7@}L^1sGn&6NqEr3GpT7m5zU^YrI4hES^yxMw&;`?7qN3Ryg|di zqP3dYIa!viU1LuviE+{J3cInusR4i_M*~<<49&xb_g%6G0MdXALthcE`u#cskcK4P zMEqbDj;rOnhGD?6?fkqzl>nqc`5XXh(sm>~>Ys7iie@*~06?0S(owLgU1R}ZZV}B{ zmaQJ+;H^abO5&qZskC%Q0HldoIRHrbDMmZ0$}0Gh%9(TyoEWl5{AXrIRRU1;)WigU z7d-gU$91P3{PKFF{VLg2lfLG`cMu#52gL!%pPZWjsFZj=knp9X)uu>0W+%D~>_>k` zi!YppoQ|B9M6~8lPfq|;f-nM>>V1B}zREKw89|mZ?sUmKJKKtqPK*%{tO{H3;3YA zcs^MqMtY@N9M2~JF7-=sJfF6$oJYiaWZ1PJo=@g`K8ScxL`kyqNxL;|+*)VzKu2J^ zNSqeU4sLjvcvO3EKmq^&03hi9>wbg^00000004Ud6ILS7H@-!X00000NkvXXu0mjf DY)Ol9 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000000000000000000000000000000000..b34687258a32bfda86c5cd4644c03ccee78938f5 GIT binary patch literal 650 zcmV;50(Jd~P)Vy!)?Z0wu#wFq~W_8{g8iEsxOw$dhn z+XTe_+Q(qx3VF#qcwalakRNt7v%_w1i{KnpPPyfYMR?+tQ>tNkIAF-44LMle&o9tW zEG2;|L$nO3>IZbt)X@*PLDP+Xz%!bj^#fkf^r|0FR-ZPzWMu5prcX&}1WajB2?7;b zOsxb=ISg;$kg1J;R+xd7g@Ai1VFoJPrytM`bI?vdU@y$UUityGFb6gD03!AStX)Dx z%uQk<=5C`z%>9uOF}IW;Vs0@<#K>}5At2@f9^e5Uu)Kf?*EFfKO-9Bxb(&l=u@R8d zBnxjKqe-52K*2fdVGh(ib#~d>tHsClq>MWIjT;6%8&}Eh`6?sP@Pb1(Y2y~gJ8w5^L z2$--TMvr*UkvMvT$?5~H|6KB*{AFw0F z9w_5MoUya|fSMQ^QN~D|QS$)5g!Ct|-$qsc$b2c`i#h$}v_H_OJyQBZy>F{1Y;5_~ zqJhm_-(FYc0YuEb_)5gwtH(sFy!>50;5(W+`T_v|F7k-bhmwt6aWAK07*qoM6N<$f|7|K@&Et; literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_check.png new file mode 100644 index 0000000000000000000000000000000000000000..bce32333d2886ab0b942bb861d238dcd39ab0bb0 GIT binary patch literal 277 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGocRXDjLn;{GUTkDN%pk&Wana$D zxlMhOE4y?eeuxT&FrE;6-&;`53{(jM4R_DnNq@G~MZA9Q-9;Ya^*%2i&%SWnd|`jS zll*;#k4N2WJ_^Ry91)j?GXH1`L%8YBEREmmHvI7YbNq^2M32~ZrY-tqrOXZ<0T zkN=rUTz-*|48B`q-t=VvZ#e9zW*AV`K2s2B_`L{*;&7wq0|;AF5*@YBcM3H|%K1&_qv z(RTauyd+l3=}-9dk4MG->+bt;TPXfd|BstLt}6-s*M@N8!E*C^_5ZD{Qr`FDa8}I` za|X8$$~}UA1P`y}P<~LhMbMzB{ZobR(e6lzgB%sMcR8Lv>JZ|4X}PSc`IcR1Lx1Uy d7+#oL4sfV9`ZX?l_~I@|#M9N!Wt~$(69C*>Ww`(V literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000000000000000000000000000000000..a7aa4c71b4f390ac778d8ed03decf7547d7a803c GIT binary patch literal 249 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGo2RvOILn;{G-nq`(?7-mSSSOpc zL{v-pviMH6Zxhl~%r~6BX{QBL2L=u5YhT}z*{*f$hq>US$K4a;<6FM`P_i+o+k4OD z1j~mp@V?+W4_nw)bOizx>m`x~%nQjOu>&$UlAm^4M`f)%Gt1K35%c jV^$qFYE`gP3hpA_yM2C*TdG$&fZv(NW^d!K#I zO;Xpnuja6I*Z{y;|++O%=w#tj=bczSxSU%%el+dKME>NfxyR%=!{c<#jRoqrEQNIwWB z3Z9FZgu?#oK9cA1EJgOBwyDlI%fD_N{*$@(N3kZzAR)sbu}e54(`3GDK~&$+U84Om zqh&i3gdl9bnV_?bJ%aPoYmE$)R6zlhS{4R{3zcsNOe7dRtm*W2k4%kwl<_r)3Wc?3~^PfMMFO!pVYWK=M ze2se7XMb4xS2eq4S5~Zt;J`Hp9;}TU%0YcX24p36wwFI;sJ~(RUWJ%uk66oUI8W2s)&D9MZ~NBGSe{AT_-HekrOtxc0=T(~6L|GGB@k-b$f zQ{Febdki2Ysv~j9tx4L%9H5%bBPN5}tM8=%RGXDf1&*D?+oFYMR!>IV6fcC$Ov~0% zeG_{xs8wkVDKcyqYmobsKPxB?Zj|Qx5GhRlj9wHhG5tP^uHb}mql%+(rFIWYRNsOsn_2ETTW z<~-_NH3K9oeQ#|YFP*szsNsmrW1RiRn=$y*DNMJ-B8dj7mrJ9~qiK-Q<9!-~{l(V- z;wy2`f?GI*dn#NDFi?RI447Kpp+FWbGzZG=kPZ-%r4~T#LY92L21t~Ira*Nm{QYSh zc$f?1T!_ZNDX57FvN8`uRE2{n*2n;!DF*gv4_l$14l-i|vfyvCL2j+^?!w^yOekjv zfZ!7DK=ye7B@klJ2{Aa_6C*iJS_rVFVG9ky=Yd?a9LSX-w3OK`L&7(N18GJF6YN(8 zSh)kGLHGl?Y$2kEsX#fq0F@#~6b)Pus#=)=Ro4TM&H!CnE85}MNFW<=c#fL$9!6)t z=p3|8q6|D{jyA~8B77RsNfZJZBE{hbT^l6oMg~Cjp2fgI1Im?A81V~2e6pPeyA43T zb{z(H$P~1D2T^`17`URW-7yx_u(G%yZ$P0XKpG}J#=%Vsgla+|P}(?rn#6(}r3FB6 zgb3Y1fsQIF^uVD5o}kd5(2JQRP;yd@i~xR z%GW4Peh!i+mm;J)3?{k;hyr9PM9GX4v^bpAQi0n?7eUrVK04*pHz3kbEZfEaTp8Bk zfVbJ*U5ajjU6{|gLG?B^&^3UltPL$;D()0gdY1~bP)x6M=!_s`;1)*8_BkvMNuc)_ zBD~%wk+h2qYA<%R&z08<6>^U0V^0c;@*)!Ge%_mRM77T)_6`;5A@3JOMf;K0t+2Ga zU~rjzMZ8j8Tt|Q|#UH*A6Mw&OETXF;?m?I73bxa_G)_+PT(`pQ>dY2B=OhNJ%Q!2f zOOPD;_vAAM9{Fmgekv(l-S;4xp={M_Vbxm9M&d>KcSQr$KJV|EM8BY7m~#R^3B*O4($O) z`hoV7R!C~P0_Qb_eF22@j;Ya3_1p@srqzbf=tbnGy$44Y@Pvj;x;{N91H-gk^h_CT zHQtXqlCboAb_=$&%-#ZrmE1cu5tcmKdZgZxmXmrzrX>1^(xlj6f)|5Gi;&omM+U`OINb~n+ET7XsXnu8 zxwl)LavL^U^6gSpqH4bl6W_Jy;4Et^Hqqgpz__ka&aTAilhT$VZ0m8_OMQ`{N%-wO z43=%qh&r^{`*lBafQ^Z`8okZHY#f7QWX5ce#-FY14+Xt%ODea9n3JW}r&+p>hrSng zOMGEt6L`}GPJJ6V6qkt)e7$-m`pVyFiS5-B#n

        1M6~B_TboJvbc!k(*|mWZ!OmI zcEVzN|5f95;eG)0#>e>`jSu8y9gHGVE__3i#GPDv_fEidJZJXA%YAiPUM?{W`i^VK z8z=IO`A&i4*w;hY$LZq`>_W>Hr|Vz`G7R}sWsb+m;bVb|`I%pNiKZ^kOC|H;zY3yU z7xqt$2bl{D`Iu{&(aocF|*!#@H z;>K*6?bA!u{Wo`y+z$<3OQlv{>OV0V+u2@m2y5ck#QVLB96bDC5jIcq;v)KSFg7?o z?lh~oEb(!dT~nUUJOBTJX`dZq>2_B06w}sdP_y31w q|9_~ayt|oplF-aZ{y@lfI&TC}xhlc>g}{PRh+pIAzN&Nuk^FDy{wf*( literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..c088e49e419dcc243fb2b7f1e91939de03878585 GIT binary patch literal 387 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%&M7!06@a;uuoF`1Zy|-@^eStQRyU zPGbAM%za7^qthD3Vue(e7D*NEC1#s6v$TdC^L?R>(-r9Tc-t_n`*9+2fOf*Ce9=j&##dg6u;QPn-JyQF4=iS+{>hm}E zvllmqu1vmi%yIMOsk=|)nC4G=x~e5R{lK|R2{S)4TD;=AmYHqeFntnpFZ+Rv(4r+K z4EMxMZ!*iRX8XZ%Cycj%spP*_17CsGFV3B;HDZ4|ZJEBg_6NRiNPH%K(?nbOLE^K} zq*>wz`oFF@eaB?3@`L1SrB$<*%075gb&UC}iFf|2w>|EsGOeu79$a=L;C@79*_EDO zTG>;z&FAs#yV&#YmEJas7q8ceo!@2_I(@f#^S*mkf3Hv9dvNbpB?Ne+^oQ|Gj-mD2 TC6Drefy?0O>gTe~DWM4fX`8Mu literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000000000000000000000000000000000..c1dcfb29024fc0eec6fb8d2135e295b5205f1323 GIT binary patch literal 109 zcmeAS@N?(olHy`uVBq!ia0vp^2_Vb}Bp6OT_L>T$m`Z~Df*BafCZDwc^3*(C978G? zlNlNV5B&dc&wTj*fBB4ajKP1J`;5;VNZ`w^TDCoqfnmn3`o>w)@0Ebe@pScbS?83{ F1OOWIB9;IE literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..ef360fe40c758ab7e8d3e168e6c2ef013515646a GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0vp^2_Vb}Bp6OT_L>T$m`Z~Df*BafCZDwc@{~PY978G? zlN%ZWKgf5mski^P@A&+mS=aI~*9HAMY}L0USsB(|s&9O`k8?Z75KmV>mvv4FO#pDq BA9(-( literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_launch.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_launch.png new file mode 100644 index 0000000000000000000000000000000000000000..476022a9806d8effbcc268295ae5b340a900d871 GIT binary patch literal 396 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 literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_speed.png new file mode 100644 index 0000000000000000000000000000000000000000..a9b45609c621a4b5eae3ef5f1b02ebe924cbc383 GIT binary patch literal 955 zcmV;s14R6ZP)?Q=MM)F+X+oHTOrizviGfG?k3noBOb7ByS}4wBf~}<z}2xaIW(4F&(Et)Wy5|TRp z;0&4C*iTz$fGL!e)bSQ4$n=ukqG2}xFf}U?Ug9LCom7^j)S71+rLyuboW?XP`HEAU zPH9OUzmw^?WTVdtOid+q%T_y*HXDoGYll69EAi@!h=wo_vC-w?qOg;u^6;XG|Z*Ylq9xj!`3W064_EVui{aL|a0hI0q%Bwh|HNbhT?r z{DtXZiHh#Yet#7J7IL*DMrjVBb&cME52Ce~$hg=c-jT#;g|;{1;R}ypx>F*<)Fo+C z;z~?UOBA$5TO%=`Sv4hQqqUMKSc+EPG5{RpZApM%(dI?lT%1O`*(!iVQ{rm0)0B}g zJc4$RycPkhni6@~f%ce$;U~0-5(^OQVBArOfoR`K7}}w=%((#ZCpdmbYb{|IgZ7?v z0E?!?>u94SZG?z7!ErmAW=`-?gEm(%WVXx!gYM&H!0C68O1dgqT-Ts+tcIa)kUO=pf40}glFyLGsm zdAxp{T5cS#2VZdXbKrs_|Kwj!bLgi(-<47W zRqUZY- z_yk4c8J0Wu2uPy1As(R>iyeFns!@tZviBU< d^E}V<&H~kY51sxIO#A=<002ovPDHLkV1oXiyHfxF literal 0 HcmV?d00001 diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..3a2398f9cb06f72d3f0ec86325cfd60533b65009 GIT binary patch literal 316 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%&M7z{u?B;uuoF`1aO8&O;6Y4j1=k z2)}fw4i$;^5N?Xi;H>&2vcB)HI++Q=;( z_EK8M(!gl>MBDBkZ|AQ#XgK3QW7M0Z`+p}!^)%jRiq6{f)8>KCv^1w?x+=ROS~uKA*F_@0GBw#NoV1El|jnd7V$8Z)C!{ zi~}y3-Df2QcDx9mFB$M;QRB}4yaoptn0X8i)F;V5yTz|jzu=>X*bc$w9<5(EL_| zv=e8Pps%AzNOF>R0HbVA^rUoFmmgL?jCQ7H*F8{^lKjLE)X1>GCA?JX>!o{>+_vtj zDgUhgb=Ft^&F`#^<^R`Y?@wr|+PUNCzTcZ;&NSX{va-zF&dyV0l*8{)G-(_A48;Zp zCJqGTV8?~z%u;`vst{AUnjL1s^yv*c6wxj)nY sor>T6CB8~E=#o7mPr=>tVPgg&ebxsLQ0NbT|L;wH) literal 0 HcmV?d00001 From 899a78fca9c0c2b66056d56e10486eb16ad256d5 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 21 Jul 2020 10:03:54 +0100 Subject: [PATCH 0733/1052] StyledPlayerControlView: Some cleanup PiperOrigin-RevId: 322317638 --- .../ui/StyledPlayerControlView.java | 185 +++++++++--------- library/ui/src/main/res/values/arrays.xml | 10 - library/ui/src/main/res/values/strings.xml | 12 +- 3 files changed, 94 insertions(+), 113 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java index 0d51f94661..536fecf7cf 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -640,23 +640,29 @@ public class StyledPlayerControlView extends FrameLayout { } // Related to Settings List View - List settingsMainTextsList = - Arrays.asList(resources.getStringArray(R.array.exo_settings_main_texts)); - TypedArray settingsIconTypedArray = resources.obtainTypedArray(R.array.exo_settings_icon_ids); + String[] settingTexts = new String[2]; + Drawable[] settingIcons = new Drawable[2]; + settingTexts[SETTINGS_PLAYBACK_SPEED_POSITION] = + resources.getString(R.string.exo_controls_playback_speed); + settingIcons[SETTINGS_PLAYBACK_SPEED_POSITION] = + resources.getDrawable(R.drawable.exo_styled_controls_speed); + settingTexts[SETTINGS_AUDIO_TRACK_SELECTION_POSITION] = + resources.getString(R.string.exo_track_selection_title_audio); + settingIcons[SETTINGS_AUDIO_TRACK_SELECTION_POSITION] = + resources.getDrawable(R.drawable.exo_styled_controls_audiotrack); + settingsAdapter = new SettingsAdapter(settingTexts, settingIcons); + playbackSpeedTextList = new ArrayList<>(Arrays.asList(resources.getStringArray(R.array.exo_playback_speeds))); - String normalSpeed = resources.getString(R.string.exo_controls_playback_speed_normal); - selectedPlaybackSpeedIndex = playbackSpeedTextList.indexOf(normalSpeed); - playbackSpeedMultBy100List = new ArrayList<>(); int[] speeds = resources.getIntArray(R.array.exo_speed_multiplied_by_100); for (int speed : speeds) { playbackSpeedMultBy100List.add(speed); } + selectedPlaybackSpeedIndex = playbackSpeedMultBy100List.indexOf(100); customPlaybackSpeedIndex = UNDEFINED_POSITION; settingsWindowMargin = resources.getDimensionPixelSize(R.dimen.exo_settings_offset); - settingsAdapter = new SettingsAdapter(settingsMainTextsList, settingsIconTypedArray); subSettingsAdapter = new SubSettingsAdapter(); subSettingsAdapter.setCheckPosition(UNDEFINED_POSITION); settingsView = @@ -1431,7 +1437,7 @@ public class StyledPlayerControlView extends FrameLayout { } selectedPlaybackSpeedIndex = indexForCurrentSpeed; - settingsAdapter.updateSubTexts( + settingsAdapter.setSubTextAtPosition( SETTINGS_PLAYBACK_SPEED_POSITION, playbackSpeedTextList.get(indexForCurrentSpeed)); } @@ -1538,6 +1544,30 @@ public class StyledPlayerControlView extends FrameLayout { } } + private void onSettingViewClicked(int position) { + if (position == SETTINGS_PLAYBACK_SPEED_POSITION) { + subSettingsAdapter.setTexts(playbackSpeedTextList); + subSettingsAdapter.setCheckPosition(selectedPlaybackSpeedIndex); + selectedMainSettingsPosition = SETTINGS_PLAYBACK_SPEED_POSITION; + displaySettingsWindow(subSettingsAdapter); + } else if (position == SETTINGS_AUDIO_TRACK_SELECTION_POSITION) { + selectedMainSettingsPosition = SETTINGS_AUDIO_TRACK_SELECTION_POSITION; + displaySettingsWindow(audioTrackSelectionAdapter); + } else { + settingsWindow.dismiss(); + } + } + + private void onSubSettingViewClicked(int position) { + if (selectedMainSettingsPosition == SETTINGS_PLAYBACK_SPEED_POSITION) { + if (position != selectedPlaybackSpeedIndex) { + float speed = playbackSpeedMultBy100List.get(position) / 100.0f; + setPlaybackSpeed(speed); + } + } + settingsWindow.dismiss(); + } + private void onLayoutChange( View v, int left, @@ -1796,38 +1826,38 @@ public class StyledPlayerControlView extends FrameLayout { } } - private class SettingsAdapter extends RecyclerView.Adapter { - private List mainTexts; - @Nullable private List subTexts; - @Nullable private TypedArray iconIds; + private class SettingsAdapter extends RecyclerView.Adapter { + private final String[] mainTexts; + private final String[] subTexts; + private final Drawable[] iconIds; - public SettingsAdapter(List mainTexts, @Nullable TypedArray iconIds) { + public SettingsAdapter(String[] mainTexts, Drawable[] iconIds) { this.mainTexts = mainTexts; - this.subTexts = Arrays.asList(new String[mainTexts.size()]); + this.subTexts = new String[mainTexts.length]; this.iconIds = iconIds; } @Override - public SettingsViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + public SettingViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { View v = LayoutInflater.from(getContext()).inflate(R.layout.exo_styled_settings_list_item, null); - return new SettingsViewHolder(v); + return new SettingViewHolder(v); } @Override - public void onBindViewHolder(SettingsViewHolder holder, int position) { - holder.mainTextView.setText(mainTexts.get(position)); + public void onBindViewHolder(SettingViewHolder holder, int position) { + holder.mainTextView.setText(mainTexts[position]); - if (subTexts == null || subTexts.get(position) == null) { + if (subTexts[position] == null) { holder.subTextView.setVisibility(GONE); } else { - holder.subTextView.setText(subTexts.get(position)); + holder.subTextView.setText(subTexts[position]); } - if (iconIds == null || iconIds.getDrawable(position) == null) { + if (iconIds[position] == null) { holder.iconView.setVisibility(GONE); } else { - holder.iconView.setImageDrawable(iconIds.getDrawable(position)); + holder.iconView.setImageDrawable(iconIds[position]); } } @@ -1838,65 +1868,43 @@ public class StyledPlayerControlView extends FrameLayout { @Override public int getItemCount() { - return mainTexts.size(); + return mainTexts.length; } - public void updateSubTexts(int position, String subText) { - if (this.subTexts != null) { - this.subTexts.set(position, subText); - } - } - - private class SettingsViewHolder extends RecyclerView.ViewHolder { - TextView mainTextView; - TextView subTextView; - ImageView iconView; - - SettingsViewHolder(View itemView) { - super(itemView); - - mainTextView = itemView.findViewById(R.id.exo_main_text); - subTextView = itemView.findViewById(R.id.exo_sub_text); - iconView = itemView.findViewById(R.id.exo_icon); - - itemView.setOnClickListener( - v -> { - int position = SettingsViewHolder.this.getAdapterPosition(); - if (position == RecyclerView.NO_POSITION) { - return; - } - - if (position == SETTINGS_PLAYBACK_SPEED_POSITION) { - subSettingsAdapter.setTexts(playbackSpeedTextList); - subSettingsAdapter.setCheckPosition(selectedPlaybackSpeedIndex); - selectedMainSettingsPosition = SETTINGS_PLAYBACK_SPEED_POSITION; - displaySettingsWindow(subSettingsAdapter); - } else if (position == SETTINGS_AUDIO_TRACK_SELECTION_POSITION) { - selectedMainSettingsPosition = SETTINGS_AUDIO_TRACK_SELECTION_POSITION; - displaySettingsWindow(audioTrackSelectionAdapter); - } else { - settingsWindow.dismiss(); - } - }); - } + public void setSubTextAtPosition(int position, String subText) { + this.subTexts[position] = subText; } } - private class SubSettingsAdapter - extends RecyclerView.Adapter { + private class SettingViewHolder extends RecyclerView.ViewHolder { + private final TextView mainTextView; + private final TextView subTextView; + private final ImageView iconView; + + public SettingViewHolder(View itemView) { + super(itemView); + mainTextView = itemView.findViewById(R.id.exo_main_text); + subTextView = itemView.findViewById(R.id.exo_sub_text); + iconView = itemView.findViewById(R.id.exo_icon); + itemView.setOnClickListener( + v -> onSettingViewClicked(SettingViewHolder.this.getAdapterPosition())); + } + } + + private class SubSettingsAdapter extends RecyclerView.Adapter { @Nullable private List texts; private int checkPosition; @Override - public SubSettingsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + public SubSettingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(getContext()) .inflate(R.layout.exo_styled_sub_settings_list_item, null); - return new SubSettingsViewHolder(v); + return new SubSettingViewHolder(v); } @Override - public void onBindViewHolder(SubSettingsViewHolder holder, int position) { + public void onBindViewHolder(SubSettingViewHolder holder, int position) { if (texts != null) { holder.textView.setText(texts.get(position)); } @@ -1915,33 +1923,18 @@ public class StyledPlayerControlView extends FrameLayout { public void setCheckPosition(int checkPosition) { this.checkPosition = checkPosition; } + } - private class SubSettingsViewHolder extends RecyclerView.ViewHolder { - TextView textView; - View checkView; + private class SubSettingViewHolder extends RecyclerView.ViewHolder { + private final TextView textView; + private final View checkView; - SubSettingsViewHolder(View itemView) { - super(itemView); - - textView = itemView.findViewById(R.id.exo_text); - checkView = itemView.findViewById(R.id.exo_check); - - itemView.setOnClickListener( - v -> { - int position = SubSettingsViewHolder.this.getAdapterPosition(); - if (position == RecyclerView.NO_POSITION) { - return; - } - - if (selectedMainSettingsPosition == SETTINGS_PLAYBACK_SPEED_POSITION) { - if (position != selectedPlaybackSpeedIndex) { - float speed = playbackSpeedMultBy100List.get(position) / 100.0f; - setPlaybackSpeed(speed); - } - } - settingsWindow.dismiss(); - }); - } + public SubSettingViewHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.exo_text); + checkView = itemView.findViewById(R.id.exo_check); + itemView.setOnClickListener( + v -> onSubSettingViewClicked(SubSettingViewHolder.this.getAdapterPosition())); } } @@ -2057,7 +2050,7 @@ public class StyledPlayerControlView extends FrameLayout { } checkNotNull(trackSelector).setParameters(parametersBuilder); } - settingsAdapter.updateSubTexts( + settingsAdapter.setSubTextAtPosition( SETTINGS_AUDIO_TRACK_SELECTION_POSITION, getResources().getString(R.string.exo_track_selection_auto)); settingsWindow.dismiss(); @@ -2066,7 +2059,7 @@ public class StyledPlayerControlView extends FrameLayout { @Override public void onTrackSelection(String subtext) { - settingsAdapter.updateSubTexts(SETTINGS_AUDIO_TRACK_SELECTION_POSITION, subtext); + settingsAdapter.setSubTextAtPosition(SETTINGS_AUDIO_TRACK_SELECTION_POSITION, subtext); } @Override @@ -2086,20 +2079,20 @@ public class StyledPlayerControlView extends FrameLayout { } } if (trackInfos.isEmpty()) { - settingsAdapter.updateSubTexts( + settingsAdapter.setSubTextAtPosition( SETTINGS_AUDIO_TRACK_SELECTION_POSITION, getResources().getString(R.string.exo_track_selection_none)); // TODO(insun) : Make the audio item in main settings (settingsAdapater) // to be non-clickable. } else if (!hasSelectionOverride) { - settingsAdapter.updateSubTexts( + settingsAdapter.setSubTextAtPosition( SETTINGS_AUDIO_TRACK_SELECTION_POSITION, getResources().getString(R.string.exo_track_selection_auto)); } else { for (int i = 0; i < trackInfos.size(); i++) { TrackInfo track = trackInfos.get(i); if (track.selected) { - settingsAdapter.updateSubTexts( + settingsAdapter.setSubTextAtPosition( SETTINGS_AUDIO_TRACK_SELECTION_POSITION, track.trackName); break; } diff --git a/library/ui/src/main/res/values/arrays.xml b/library/ui/src/main/res/values/arrays.xml index 382a899afb..8b326c3d4d 100644 --- a/library/ui/src/main/res/values/arrays.xml +++ b/library/ui/src/main/res/values/arrays.xml @@ -34,14 +34,4 @@ 1.5x 2x - - - @string/exo_controls_playback_speed - @string/exo_controls_audio_track - - - - @drawable/exo_styled_controls_speed - @drawable/exo_styled_controls_audiotrack - diff --git a/library/ui/src/main/res/values/strings.xml b/library/ui/src/main/res/values/strings.xml index 210f35f48c..dc71645878 100644 --- a/library/ui/src/main/res/values/strings.xml +++ b/library/ui/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Shuffle off VR mode + Download @@ -56,6 +57,7 @@ Download failed Removing downloads + Video @@ -90,13 +92,10 @@ CC %1$.2f Mbps + %1$s, %2$s - - Audio track @@ -104,9 +103,8 @@ Normal - - %1$.2fx - + %1$.2fx + 00:00:00 Hide player controls + + Playback progress + + Settings + + Hide additional settings + + Show additional settings + + Enter fullscreen + + Exit fullscreen - Previous track + Previous - Next track + Next Pause @@ -30,8 +42,12 @@ Stop Rewind + + Rewind %d seconds Fast forward + + Fast forward %d seconds Repeat none @@ -44,6 +60,18 @@ Shuffle off VR mode + + Disable subtitles + + Enable subtitles + + Speed + + Normal + + %1$.2fx + + 00:00:00 Download @@ -95,56 +123,4 @@ %1$s, %2$s - - - Playback speed - - Normal - - %1$.2fx - - - 00:00:00 - - Back to previous button list - - See more buttons - - Playback progress - - Settings - - Tap to hide subtitles - - Tap to show subtitles - - - Rewind %d seconds - - - Fast forward %d seconds - - Enter fullscreen - - Exit fullscreen diff --git a/library/ui/src/main/res/values/styles.xml b/library/ui/src/main/res/values/styles.xml index f738410781..2f083f18c9 100644 --- a/library/ui/src/main/res/values/styles.xml +++ b/library/ui/src/main/res/values/styles.xml @@ -85,7 +85,7 @@ - - - - - - - + + - - - - - -